8. Programowanie obiektowe w JS-ie

Wyzwania:

  • dowiesz się czym jest referencja,
  • poznasz zasady stojące za ustaleniem wartości this w funkcji,
  • zapoznasz się z programowaniem obiektowym,
  • poznasz zagadnienia klas i instancji,
  • stworzysz kilka funkcjonalności większego projektu.

Wstęp

Do tej pory udało nam się poznać JS na tyle, aby stworzyć naszą pierwszą aplikację. Możesz jednak mieć wrażenie, że kod naszego bloga był pisany trochę chaotycznie i łatwo w nim się zgubić. Dlatego w tym module skupimy się na programowaniu obiektowym (object-oriented programming/OOP), które pozwoli nam na jego znaczne uporządkowanie.

W tej chwili wszystkie funkcje były przechowywane w naszym kodzie niejako "luzem". Funkcja po funkcji, niezależnie od ich roli. Programowanie obiektowe zmieni tę postać rzeczy. Pozwoli nam bowiem na grupowanie funkcjonalności tyczących się konkretnego zadania w pojedyncze obiekty. Tak, że np. wszystkie funkcje odpowiedzialne za działania na tagach znajdziemy w jednym obiekcie, a na autorach w drugim. Dzięki temu w razie potrzeby nie będzie trzeba mozolnie przeszukiwać całego pliku w poszukiwaniu jednej funkcjonalności. Zamiast tego od razu przejdziemy do konkretnego obiektu, co znacznie przyśpieszy jej znalezienie. Zapewne domyślasz się, że może to mieć ogromne znaczenie. Zwłaszcza w większych aplikacjach.

Zanim jednak faktycznie zajmiemy się programowaniem obiektowym, musimy jeszcze na chwilę się zatrzymać. W poprzednich modułach korzystaliśmy z trzech mechanizmów JS-a, których tak naprawdę wprost nie omówiliśmy. Wiesz np. czym są obiekty, ale czy mówi Ci coś słowo "referencja"? Rozumiesz też, że jako parametr metody addEventListener wpisujemy zmienną kierującą do funkcji, ale jak jest jest ona później uruchamiana? Tego już nie wiemy. Korzystaliśmy również ze słowa kluczowego this, ale czy zdajesz sobie sprawę od czego zależy jego wartość? No właśnie. Dlatego też, zanim przejdziemy dalej, zajmiemy się właśnie tymi tematami. Tak, aby to co dotychczas robiliśmy, nie było już dla Ciebie żadną tajemnicą.

8.1. Referencje, magiczne słowo this i podstawy OOP

Typy, wartości i referencje

Zgodnie z planem, zaczniemy od poznania idei referencji. Czym one w ogóle są? Do czego służą? Najłatwiej zrozumiesz to na praktycznym przykładzie. Wyobraź sobie następującą sytuację:

let personOne = 'John';
let personTwo = personOne;
personTwo = personOne + ' II';
console.log('Person one', personOne);
console.log('Person two', personTwo);

Jak myślisz, jaka będzie końcowa wartość zmiennych personOne i personTwo?

Zapewne łatwo odpowiesz na to pytanie. W pierwszej linijce deklarujesz zmienną personOne o wartości John. Następnie tworzysz zmienną personTwo, która ma być kopią personOne. Na końcu modyfikujesz tę kopię (a więc personTwo), dodając jej tekst II. Tym samym na końcu te zmienne powinny mieć następujące wartości: John oraz John II.

Sprawdźmy:

image

Przypuszczamy, że nie pojawiły się tutaj u Ciebie żadne wątpliwości. W takim razie czas na kolejny przykład:

const personOne = { firstName:  'John', lastName: 'Doe' };
const personTwo = personOne;
personTwo.firstName = 'Amanda';
console.log('Person one', personOne);
console.log('Person two', personTwo);

Jak myślisz, co konsola pokaże tym razem po wykonaniu wszystkich instrukcji?

Wydaje się, że całość jest bardzo podobna do pierwszego przykładu. Tworzymy obiekt, następnie kopiujemy go do nowej stałej. Potem modyfikujemy kopie i pokazujemy zawartość obu obiektów. Wydaje się więc, że pierwszy powinien pozostać niezmieniony, ale drugi wyglądać tak: { firstName: 'Amanda', lastName: 'Doe' }. Jak myślisz, czy tak właśnie będzie?

Jeśli uważasz, że nie, to masz rację.

image

Jak widzisz, zamiast dwóch różnych obiektów, otrzymaliśmy dwa takie same. Oba mają atrybut firstName o wartości Amanda. Jak do tego doszło? Odpowiedź będzie dość krótka.

Jak udało nam się już wcześniej udowodnić, w przypadku prostych typów danych (tzw. prymitywów) JS, przy próbie przypisania, kopiuje wartość. Dzieje się tak więc ze stringami (jak na przykładzie pierwszym), ale tak samo JS zachowałby się w przypadku liczb, wartości boolean oraz innych typów prostych.

Poniżej przykład z innym “prymitywem”, czyli typem number.

let personAgeOne = 10;
let personAgeTwo = personAgeOne;
personAgeTwo += 2;
console.log('Person one', personAgeOne); //10
console.log('Person two', personAgeTwo); //12

Złożone typy danych będą już jednak zachowywały się inaczej. W przypadku typów złożonych (np. obiekty, tablice, funkcje) JS stosuje mechanizm referencji. Na czym on polega? Wartości złożonych typów danych z reguły są dość duże. Oczywiście w jednym przypadku mogą być ogromne, a w innym znacznie mniejsze, ale co do zasady – są znacznie bardziej “pamięciożerne” niż wartości typów prostych. Dlatego też JS, zamiast bezmyślnie je kopiować, w przypadku przypisania do nowej zmiennej czy stałej, przekazuje im tylko “adres” do oryginalnego obiektu. Zatem nie tworzymy kopii, lecz ta nowa zmienna/stała staje się tylko czymś w rodzaju “linku” (referencją) kierującego do oryginału.

Dlaczego JS tak postępuje? Ze względu na wydajność. Po co trzymać w pamięci dwa lub więcej takich samych obiektów? Przecież tylko niepotrzebnie zajmowałyby tam miejsce.

Mając taką wiedzę, wróćmy na chwilę do naszego przykładu numer dwa:

const personOne = { firstName:  'John', lastName: 'Doe' };
const personTwo = personOne;
personTwo.firstName = 'Amanda';
console.log('Person one', personOne);
console.log('Person two', personTwo);

Teraz już wiemy co tu się naprawdę dzieje.

const personOne = { firstName:  'John', lastName: 'Doe' };

Najpierw JS tworzy nową stałą o nazwie personOne i przypisuje do niej adres (referencję) do nowo utworzonego obiektu w pamięci{ firstName: 'John', lastName: 'Doe' }. Dlaczego adres, a nie kopię zawartości? To już wiemy, bo w przypadku typów złożonych (a obiekt takim jest) JS domyślnie przypisuje zamiast kopii tylko adres (referencję). personOne tym samym od początku jest tylko i wyłącznie odnośnikiem do miejsca w pamięci, gdzie jest przechowywany nasz nowy obiekt.

Następnie tworzona jest nowa stała personTwo, która jako wartość również otrzymuje adres (referencję) do personOne. Tak naprawdę można powiedzieć, że personTwo od samego początku nie jest “własnym bytem”. Zamiast tego od razu również staje się tylko odnośnikiem (referencją) do tego samego obiektu co personOne.

W kolejnym kroku staramy się zmienić wartość właściwości firstName obiektu personTwo. Tylko że czym w tej chwili jest tak naprawdę ta stała? Adresem do tego samego obiektu co personOne. Dlatego też, próbując dostać się do personTwo.firstName, tak naprawdę dostajemy się do tego samego miejsca, do którego pokierowałoby nas również personOne.firstName. Czyli modyfikując personTwo.firstName, zmieniamy tak naprawdę tylko jeden pierwotny obiekt, do którego kierują zarówno personOne, jak i personTwo.

console.log('Person one', personOne);

Pod koniec pokazujemy w konsoli wartość obu stałych. console.log('Person one', personOne) skieruje nas za pomocą referencji bezpośrednio do pierwotnego obiektu, a więc konsola pokaże nam obiekt { firstName: 'Amanda', lastName: 'Doe' }.

console.log('Person two', personTwo);

Ta linijka kodu powinna nam pokazać wartość personTwo. Jednak czym jest personTwo? Odnośnikiem do tego samego obiektu w pamięci co personOne, a więc w konsoli zobaczymy dokładnie to samo, co wyżej – wartość zmodyfikowanego obiektu pierwotnego.

Podsumowując, tak naprawdę od samego początku personOne i personTwo kierowały nas do tego samego obiektu w pamięci. Nie ważne więc czy próbowaliśmy modyfikować personOne czy personTwo, modyfikowaliśmy jeden i ten sam obiekt.

Możemy podsumować dotychczasową wiedzę następująco: przy próbie przypisania do zmiennej/stałej wartości typu prostego, JS przypisze jej kopię, a w przypadku typu złożonego – jej referencję.

image

Dla utrwalenia zróbmy jeszcze jeden mały przykład:

const namesOne = ['John', 'Amanda'];
const namesTwo = namesOne;
namesTwo.push('Thomas');

console.log(namesOne);
console.log(namesTwo);

Jaki będzie wynik działania powyższej funkcji? Co pojawi się w konsoli? Zastanów się na spokojnie.

W konsoli wyświetli się dwa razy ta sama zmodyfikowana tablica ['John', 'Amanda, 'Thomas'].

const namesOne = ['John', 'Amanda'];

W pierwszej linijce JS tworzy nowy obiekt w pamięci (tablicę ['John', 'Amanda']) i przypisuje jego referencję do nowej stałej namesOne. Dlaczego przypisuje ten obiekt w formie referencji, a nie kopii? Bo obiekt to typ złożony, a przy tych JS, jak pisaliśmy już wcześniej, korzysta właśnie z mechanizmu referencji.

const namesTwo = namesOne;

Następnie tworzymy stałą namesTwo i staramy się przypisać jej wartość namesOne. Stała namesOne jest jednak tylko referencją do obiektu pierwotnego, dlatego namesTwo również staje się referencją (adresem) do tej samej tablicy.

Zauważ więc, że od tej chwili mamy dwie stałe, ale obie kierują tak naprawdę do tego samego obiektu w pamięci, naszej tablicy z imionami.

namesTwo.push('Thomas');

W kolejnym kroku staramy się dodać do namesTwo nowy element. namesTwo jest tylko referencją do naszej tablicy imion w pamięci (podobnie, jak namesOne), więc push jest wykonywane właśnie na niej. Nasza tablica zapisana w pamięci otrzymuje na tym etapie nowy element Thomas.

console.log(namesOne);
console.log(namesTwo);

Na końcu pokazujemy wartość obu stałych. Obie kierują pod ten sam obiekt w pamięci, więc pokażą dokładnie to samo – tablicę z trzema elementami.

Jeśli na tym etapie wszystko jest dla Ciebie jasne, to możemy iść dalej. Jeśli jednak nie czujesz się zbyt pewnie, to postaraj się przerobić oba przykłady jeszcze raz. Na spokojnie.

Referencje, kopie i funkcje

Jak wygląda sytuacja z funkcjami? Czy przekazując jakieś dane w formie parametru, też przekazujemy – zależnie od typu – kopię albo referencję? Jak najbardziej!

Pamiętaj, że ustawienie wartości parametru, koniec końców, nie różni się zbytnio od procesu zwykłej deklaracji zmiennej. Pozwala ono po prostu na przypisanie do zakresu funkcji jakiejś zmiennej o nazwie równej nazwie parametru i wartości równej przekazanemu argumentowi. Zatem skoro tutaj też dochodzi do przypisywania wartości, to musimy trzymać się tych samych zasad co wcześniej.

const label = 'Names of people';
const names = ['John', 'Amanda'];

function prepareAndShowNames(namesArr, title) {
  title = '==' + title + '==';
  namesArr.push('Thomas');
  console.log(title, namesArr);
}

prepareAndShowNames(names, label);

Nie ma tu niczego specjalnie skomplikowanego, ale żeby nie było żadnych wątpliwości, powiedzmy najpierw, co dokładnie ma tu robić funkcja prepareAndShowNames. Powinna ona otrzymywać tablicę imion, a także tytuł. Zadaniem tej funkcji jest dodanie do tablicy nowego imienia (Thomas), a następnie pokazanie jej zawartości zaraz po tytule.

Postaraj się uruchomić ten kod, np. na CodePenie. Jeśli to zrobisz, to od razu rzucą Ci się w oczy dwie rzeczy.

image

Po pierwsze okazuje się, że JavaScript nie ma problemu z tym, że staramy się przypisywać coś do parametru. JS spokojnie poradził sobie z linijką title = '==' + title + '=='. Potwierdza się tylko to, o czym pisaliśmy wcześniej. Ustalenie parametru funkcji kończy się, tak czy inaczej, zadeklarowaniem zmiennej (w tej sytuacji o nazwie title), dlatego też nie otrzymujemy w konsoli żadnego komunikatu, jakoby title była niezadeklarowana. Nie widzimy tego wprost, ale "pod maską" JS naprawdę deklaruje parametr jako zwykłą zmienną i tak ją potem traktuje. Dlatego też bez problemu przypisuje do niej nową wartość, bo w istocie była zadeklarowana – właśnie na etapie inicjacji wykonania funkcji.

Po drugie, okazuje się, że po wykonaniu funkcji zmodyfikowana została nie tylko tablica namesArr, wewnątrz której pracowaliśmy, ale również przekazywany w formie parametru oryginał – tablica dostępna pod stałą names. Za to label pozostał bez zmian. Dlaczego?

Jeśli wiemy już, że w przypadku przekazywania danych do funkcji stosujemy te same zasady co przy najprostszym przypisywaniu, to możemy łatwo rozszyfrować, co tu się właściwie stało.

const label = 'Names of people';

Zacznijmy od pierwszej linijki. Tworzymy nową stałą i przypisujemy do niej kopię wartości tekstowej stringa. Dlaczego kopię, a nie referencję? To już wiemy. W przypadku przypisywania wartości typów prostych JS kopiuje wartość.

Zatem możemy powiedzieć, że stała label faktycznie ma wartość tekstową Names of people.

const names = ['John', 'Amanda'];

Następnie staramy się stworzyć drugą stałą, tym razem o nazwie names. Tutaj mamy już jednak próbę przypisania obiektu, więc JS skorzysta z mechanizmu referencji. Co tu się właściwie stanie?

JS utworzy w pamięci nowy obiekt – tablicę ['John', 'Amanda'], a adres (referencja) do niej zostanie przypisany do nowoutworzonej stałej names. Krótko mówiąc, od tego momentu names jest tylko i wyłącznie odnośnikiem do właśnie utworzonego obiektu w pamięci – naszej tablicy ['John', 'Amanda'].

prepareAndShowNames(names, label);

Zapewne zdajesz sobie sprawę, że JS po zauważeniu deklaracji funkcji od razu jej nie wykona. Możemy więc założyć, że zapisze ją w pamięci, ale nie uruchomi, tylko pójdzie dalej. I dopiero teraz, już w tej konkretnej powyższej linijce, w końcu tę funkcję włączy.

Od czego zacznie JS przy jej wykonywaniu? Od zadeklarowania zmiennych z parametrów. Do namesArr postara się przypisać wartość stałej names, a do title wartość stałej label. Zauważ, że faktycznie dochodzi tu do próby przypisania.

Teraz, znając już zasady, możemy łatwo ustalić, jakie wartości przyjmą nasze parametry. Skoro names odnosi się swoją referencją do obiektu złożonego (tablicy), to do namesArr przypiszemy również po prostu referencję do miejsca w pamięci, gdzie ten obiekt się znajduje. W przypadku title mamy typ prosty, możemy więc założyć, że w tej sytuacji JS po prostu skopiuje do title wartość stałej label.

Zatem na tym etapie wiemy już, że przy wywołaniu prepareAndShowNames w funkcji tej pojawiają się dwie zmienne: namesArr, która jest referencją do tego samego obiektu w pamięci co names, czyli również kieruje nas do tablicy ['John', 'Amanda'], oraz title, której wartość będzie równa tekstowi Names of people.

title = '==' + title + '==';

W następnej linijce staramy się tylko lekko udekorować otrzymany tytuł, np. Names zostanie zamienione na ==Names of people==.

Stała czy zmienna?

Na tym etapie może pojawić się w Twojej głowie jedno pytanie. Mówimy, że ustalając parametry w funkcji, tak naprawdę deklarujemy dodatkowe zmienne w jej zakresie. Tylko czy na pewno zmienne, a może stałe? Skąd mamy to wiedzieć? Najlepiej spojrzeć na nasz mały eksperyment.

Zauważ, że linijka kodu title = '==' + title + '=='; nie spowodowała błędu. To dowodzi, że title (czyli drugi parametr) jest deklarowany jako zmienna. Gdyby był stałą, to nie można by było zmodyfikować jego wartości, a konsola pokazałaby błąd.

W takim razie idźmy dalej.

namesArr.push('Thomas');

W kolejnym kroku JS postara się dodać do namesArr nowy element. Co istotne namesArr, jak już wiemy, jest tylko referencją do naszej tablicy z imionami, którą przechowujemy w pamięci. Tym samym, próbując dodać coś do namesArr, za każdym razem odnosimy się do tej samej tablicy w pamięci, do której prowadzi również stała names. Jednej i tej samej tablicy.

console.log(title, namesArr)

Kolejną linijkę na pewno jesteś w stanie łatwo rozszyfrować. Staramy się pokazać wartość title i namesArr. title to zwykły tekst ==Names of people== i on zostanie pokazany jako pierwszy. namesArr kieruje za to pod adres w pamięci, gdzie jest nasza zmodyfikowana przed chwilą tablica z imionami. Zatem jako druga, zostanie pokazana właśnie ona.

console.log(label, names);

Po wykonaniu funkcji pozostaje nam jeszcze jedna instrukcja. Jako label konsola pokaże wartość tekstową stałej label, czyli wciąż Names. Jak wiemy, do funkcji była przekazywana tylko kopia label, więc żadne modyfikacje poczynione w funkcji na kopii nie wpłynęły na oryginał. Za to już jako names JS pokaże nam dokładnie to samo, co przed chwilą widzieliśmy jako namesArr. Dlaczego? Bo i names i namesArr prowadzą tak naprawdę do tego samego jednego elementu.

Kopiowanie złożonych danych

Coraz lepiej orientujesz się w temacie. Powstaje jednak jedno pytanie. Czy JS zawsze zamiast kopiować złożone dane, przekazuje tylko referencje do nich? Czy da się go jakoś zmusić, żeby zachował się inaczej? W końcu w naszym przykładzie wyżej na pewno wolelibyśmy otrzymać kopię danych, do których prowadzi names. Dzięki temu namesArr mogłoby być w naszej funkcji dowolnie modyfikowane, bez obaw, że "zepsujemy" coś w oryginale.

Odpowiedź jest prosta – da się. Nie będziemy jednak póki co zaprzątać sobie tym głowy. Na razie wystarczy, że wiesz, iż taka opcja istnieje. A jaka dokładnie? Opowiemy o tym trochę później, kiedy faktycznie będziemy musieli skorzystać z tej wiedzy w praktyce.

Ćwiczenia

Czas podsumować nową dawkę wiedzy krótkim quizem.

Pytanie 1

Przy próbie przypisywania wartości, JS – zależnie do typu – kopiuje wartość lub przekazuje referencję (adres). Od czego to zależy?

Od typu. Wartości typów złożonych (czyli np. tablice, obiekty, funkcje) są przekazywane przez referencję, a prostych (liczby, tekst itd.) jako kopia.

Pytanie 2

W jaki sposób są przekazywane dane do funkcji? W formie kopii czy referencji?

To zależy od typu danych, które przekazujemy. Wartości typów złożonych (czyli np. tablice, obiekty, funkcje) są przekazywane przez referencję, a prostych (liczby, tekst itd.) jako kopia.

Pytanie 3

const namesOne = ['John', 'Amanda'];

let namesTwo = namesOne;
namesTwo.push('Thomas');
namesTwo = [];

console.log(namesOne);
console.log(namesTwo);

Jakie będą końcowe wartości namesOne i namesTwo?

To trochę podchwytliwe pytanie.

namesOne będzie referencją do tablicy o wartości ['John', 'Amanda', 'Thomas'].

namesTwo będzie referencją do pustej tablicy.

Zauważ, że w instrukcji namesTwo = [] mówimy JS-owi: utwórz nową tablicę i przypisz referencję do niej jako wartość namesTwo. A to oznacza, że od tej chwili namesTwo przestaje być odnośnikiem do pierwszej tablicy, a staje się odnośnikiem do drugiej – tej nowej i pustej. namesOne i namesTwo prowadzą więc od tego momentu do innych tablic.

Pytanie 4

const person = { firstName: '', lastName: '' };
const name = person.firstName;

const personOne = person;
personOne.firstName = 'John';
personOne.lastName = 'Doe';

const personTwo = person;
personTwo.firstName = 'Amanda';
personTwo.lastName = 'Doe';

console.log(name, personOne, personTwo);

Co pokaże się w konsoli?

'',
{ firstName: 'Amanda', lastName: 'Doe' },
{ firstName: 'Amanda', lastName: 'Doe' }

W przypadku personOne i personTwo zapewne nie masz wątpliwości. person, personOne i personTwo są referencją do dokładnie tego samego obiektu w pamięci. Nie ważne więc, gdzie robisz zmiany, modyfikujesz ten sam jeden obiekt.

const name = person.firstName;

Ta linijka mogła Cię zmylić, ale pamiętaj, że person.firstName to zwykły string (typ prosty), a jako taki został przypisany do name jako kopia. Tym samym, żadne dalsze zmiany w obiekcie, do którego kierował person nas nie interesują. Jego wartość pozostanie do końca taka sama (pusty string).

Pytanie 5

Czy istnieje opcja skopiowania obiektu?

Tak.

Funkcje callback

Dość szczególnym wykorzystaniem mechanizmu referencji są funkcję callback. Korzystaliśmy już z nich w poprzednim module. Chociażby wtedy, kiedy przekazywaliśmy referencję do naszych funkcji, jako parametr metody addEventListener.

Np.

link.addEventListener('click', tagClickHandler);

Idea jest tutaj dość prosta. Przekazujemy jako jeden z argumentów funkcji inną funkcję i ta jest wywoływana wtedy, kiedy pierwsza z nich uzna, że jest taka potrzeba, np. kiedy funkcja pierwsza skończy jakiś proces.

Spójrz tylko na przykład:

function hello(name) {
  console.log('Hey', name);
}

function runOtherFunc(callback) {
  const val = prompt('Pass the value!');
  callback(val);
}

runOtherFunc(hello);

Jak zadziała powyższy kod?

Na samym początku zostaną zadeklarowane dwie funkcje – hello i runOtherFunc. Oczywiście wiesz już, że na tym etapie nie zostaną one automatycznie uruchomione, a zaledwie zapisane w pamięci.

runOtherFunc(hello);

Dopiero ostatnia linijka faktycznie uruchamia jedną z nich, funkcję runOtherFunc. Spójrz dokładnie na to, co jest przekazywane jako parametr callback tej funkcji.

Jako parametr przekazujemy kolejną funkcję! A dokładnie referencję do funkcji dostępnej pod hello. Oznacza to, że po wywołaniu, funkcja runOtherFunc od razu zadeklaruje w swoim scope (zakresie) zmienną callback o wartości… no właśnie o wartości czego? Jak już wiesz, w tym momencie dochodzi do próby przypisania wartości do zmiennej. Wiemy też, że JS zależnie od sytuacji przekazuje kopię albo referencję – zależy to od typu danych, tego, czy jest on złożony, czy prosty. Jaki jest on w tej sytuacji? Funkcja to ewidentnie typ złożony, tym samym do funkcji runOtherFunc nie trafi kopia funkcji hello, lecz tylko referencja (adres) do niej. Możemy skrócić to do następującego stwierdzenia: callback i hello kierują tak naprawdę do tej samej funkcji — tego samego miejsca w pamięci. To bardzo istotne, bo dzięki temu wiemy, że wywołując potem w kodzie callback, tak naprawdę uruchamiamy po prostu tę samą funkcję, którą uruchomiłoby wywołanie hello.

Po ustaleniu zawartości callback, funkcja przechodzi do zapytania o wartość, którą ma przypisać do stałej val. Naturalnie może to być dowolny string. Funkcja w żaden sposób nie waliduje tego, co dostanie. Zamiast tego przechodzi po prostu dalej.

I tu dochodzimy do najciekawszego fragmentu tego kodu. Uruchamiamy callback z parametrem, przy czym jego wartość ma być równa temu, co wpisano właśnie przed chwilą do val.

Wiemy już do czego prowadzi callback – do funkcji, która jest też przypisana pod hello. Wywołując callback, uruchamiamy więc tak naprawdę tę funkcję:

function (name) {
  console.log('Hey', name);
}

Wiemy też, że wywołując ją, jako pierwszy parametr (name) przekazujemy do niej wartość val (callback(val)). Tym samym, wpisując w pole wygenerowane przez prompta np. wartość John, możemy oczekiwać, że efektem działania programu będzie wyświetlanie w konsoli tekstu Hey John. Możesz to łatwo przetestować.

Jakie jest zastosowanie funkcji callback? Bardzo szerokie. Jej idea to jeden z podstawowych konceptów języka. Jest wykorzystywana m.in. w wielu wbudowanych w JS-a metodach jak addEventListener, map czy reduce (tych dwóch ostatnich jeszcze nie używaliśmy). Dobrze sprawdza się również w przypadku funkcji asynchronicznych, czyli takich, które potrafią wykonywać coś "w tle", niezależnie od wątku głównego. Często wykorzystuje się wtedy callback, jako referencję do funkcji, która ma być wywołana dopiero w momencie zakończenia asynchronicznej operacji. O tego typu funkcjach powiemy jednak trochę dalej. W tym module jeszcze się tym nie zainteresujemy.

Na co warto uważać?

W przypadku wykorzystywania funkcji callback musimy jednak uważać na jedną rzecz. Pamiętaj, aby przekazywać referencję do funkcji, a nie to, co ona zwraca. Spójrz tylko na poniższy kod.

function hello(name) {
  console.log('Hey', name);
}

function runOtherFunc(callback) {
  const val = prompt('Pass the value!');
  callback(val);
}

runOtherFunc(hello());

O ile wcześniejszy przykład, w którym przy hello nie pojawiły się nawiasy, zadziałał bezbłędnie, to tutaj mielibyśmy już problem. Dla JS-a dwa skierowane do siebie nawiasy są równoznaczne z rozkazem “wykonaj tę funkcję”. Tym samym funkcja zostanie wykonana, w naszej sytuacji zwróci wartość undefined (brak słowa kluczowego return jest równe return undefined) i to właśnie ona zostanie przekazane jako wartość parametru callback. Tym samym runOtherFunc będzie starało się wywołać… wartość undefined, a jak zapewne się domyślasz, to nie ma prawa się udać.

Czasem jednak chcemy przekazać funkcję, od razu informując, z jakim argumentem ma się ona wykonać. Zresztą, taka sytuacja zdarzyła nam się nawet w aplikacji z grą kamień, papier, nożyce. Mieliśmy nasłuchiwacz, który powinien włączać funkcję playGame i od razu przekazywać jej informację, co wybrał gracz. Właśnie poprzez argument. Co w takiej sytuacji zrobić? Z jakich technik możesz skorzystać? Najprościej możemy po prostu “opakować” wywołanie takiej funkcji w inną funkcję. Właśnie tak na pewno udało Ci się to rozwiązać również w module z grą, prawda?

function hello(name) {
  console.log('Hey', name);
}

function runOtherFunc(callback) {
  const val = prompt('Pass the value!');
  callback();
}

runOtherFunc(function() { hello('John'); });

Spójrz tylko na powyższy przykład.

Tym razem jako callback przekazujemy referencję do zupełnie nowej prostej funkcji function { hello('John'); }. Co istotne, nie włączamy jej (nie ma tu nawiasów ()), przekazujemy tylko referencję. Zatem udało nam się załatwić pierwszy problem. callback będzie tutaj referencją do funkcji function() { hello('John'); }, a nie tylko wartością działania funkcji, jak to było wcześniej.

A co stanie się dalej, kiedy dojdzie do wykonania funkcji callback w runOtherFunc? Uruchamiając callback, wystartujemy tak naprawdę tę funkcję:

function() {
  hello('John');
}

Co ona w takiej sytuacji zrobi? Jej kod jest bardzo prosty: włączy funkcję, do której kieruje właśnie hello! A jako parametr przekaże tekst 'John'! Czyli ostatecznie i tak włączy się funkcja ukryta pod hello, tak jak chcieliśmy od samego początku – i co ważne, jest ona uruchamiana z założonym parametrem.

Jak widzisz, faktycznie udało nam się przemycić wywołanie hello do funkcji runOtherFunc wraz z informacją o wartości parametru. Oczywiście potrzebowaliśmy tutaj konia trojańskiego (pośrednika) w postaci dodatkowej funkcji, ale… udało się!

Zapewne podobnie wyglądało to w Twojej aplikacji z grą:

rockBtn.addEventListener('click', function() { playGame(1); });

Mamy rację? ;) Czyli już wcześniej zdarzyło Ci się skorzystać z tej techniki. Tylko że teraz już wiesz, dlaczego była nam ona w ogóle potrzebna.

Tajemniczy argument w addEventListener

Przy okazji wyjaśniła się kolejna magiczna rzecz z poprzednich modułów, a mianowicie argument event w metodzie addEventListener. Zapewne pamiętasz, że niektóre funkcje w aplikacji z blogiem, oczekiwały na pewien tajemniczy argument event. Mówiliśmy, że jest to obiekt z informacjami o zdarzeniu, a jedną z jego metod jest preventDefault, a więc funkcja, która potrafi blokować domyślne zachowanie przeglądarki. Skąd jednak on pochodził? Tego nie powiedzieliśmy. A w końcu, przy samym dodawaniu nasłuchiwacza, nic o żadnym obiekcie event nie wspominamy.

Spójrz tylko na jedną z pętli z tamtego projektu.

for(let link of links) {
  link.addEventListener('click', tagClickHandler);
}

Staramy się tutaj przejść po każdym linku z kolekcji linków (links) i dla każdego z nich dodajemy nasłuchiwacz. Określamy, że JS ma obserwować każdy z linków i oczekiwać na event (zdarzenie) kliknięcia. Jeśli je wykryje, musi uruchomić funkcję tagClickHandler. Nie ma tu jednak słowa o tym, że funkcja ta otrzyma jakieś informacje. Skąd się więc one biorą?

Cóż, skoro już wiesz, jak działają funkcję callback, to możesz się tego łatwo domyślić.

Najprawdopodobniej metoda addEventListener wygląda mniej więcej tak:

addEventListener: function(eventType, callback) {
  // ...
  const eventObj = { preventDefault: ..., target: ...}
  callback(eventObj)
}

Jest to po prostu funkcja, która oczekuje na dwa argumenty – informacje o typie zdarzenia, który chcemy obserwować (eventType) oraz referencje do funkcji, która ma się uruchomić po jego wykryciu (callback). Oczywiście jako callback przekazujemy zawsze referencje do funkcji, to już wiesz. Z taką wiedzą, łatwo możemy zrozumieć, co dzieje się dalej.

Kiedy JS wykryje, że dane zdarzenie rzeczywiście ma miejsce w obserwowanym elemencie, uruchamia argument callback. Jest on tylko referencją do przekazanej wcześniej funkcji. Tym samym uruchamiając callback, tak naprawdę uruchamiamy oryginalną przekazaną do tego parametru funkcję. W naszym przykładzie wyżej tą funkcją był tagClickHandler. Włączając więc callback, metoda addEventListener włączyłaby tak naprawdę tagClickHandler.

Jeszcze ważniejsze jest jednak to, w jaki sposób ją wywołujemy. Zobacz, że ta funkcja nie tylko jest uruchamiana, ale dodatkowo otrzymuje jeszcze jakieś dane! Właśnie wspomniany wcześniej obiekt z informacjami o zdarzeniu! Teraz wystarczy, żeby taka funkcja faktycznie taki argument "odbierała".

function tagClickHandler(event) {
  ...
}

Jeśli to zrobi, to może potem z niego skorzystać, np. uruchamiając metodę preventDefault. Oczywiście, jeśli w funkcji, którą przekazujemy, nie przygotujemy żadnego parametru, nic się nie stanie. Metoda addEventListener i tak taką funkcję uruchomi. Owszem, przekazany obiekt ze zdarzeniem nie będzie odebrany, ale czy musi? Wcale nie. Podsumowując, metoda addEventListener zawsze uruchamia otrzymaną funkcję callback wraz z obiektem z informacjami o zdarzeniu i jeśli chcemy, to możemy je odebrać. Wystarczy, że nasza funkcja callback będzie oczekiwała na przynajmniej jeden argument. Jeśli będzie, to właśnie on zostanie obdarowany takim obiektem.

Ćwiczenia

Pytanie 1

Jak myślisz, czy funkcja może przyjąć jako parametry więcej niż jedną funkcję callback? Czy nazwy tych parametrów mogą być dowolne?

Jak najbardziej. Działa to tak samo, jak z każdym innym typem danych. Podobnie jak możemy przekazywać dwa, trzy albo i więcej stringów czy obiektów, tak samo możemy czynić to z funkcjami.

Np.

function foo(cbOne, cbTwo) {
  cbOne();
  cbTwo();
}

foo(function() { console.log('One!'); }, function() { console.log('Two!'); })

...pokaże w konsoli tekst One! oraz Two!.

Nazwy parametrów mogą być oczywiście dowolne, chociaż często dla ułatwienia czytelności kodu korzystamy z nazw callback lub cb.

Pytanie 2

Jak uważasz, czy funkcja przekazywana jako callback do metody addEventListener może odbierać obiekt z informacjami o zdarzeniu do argumentu o innej nazwie niż event?

Oczywiście. Metoda addEventListener uruchamia funkcję przekazaną jako callback zawsze z jednym argumentem. Przekazuje w nim obiekt z informacjami o zdarzeniu. Jeśli ta funkcja będzie posiadała przynajmniej jeden parametr, to właśnie on otrzyma ten obiekt, ale jego nazwa nie ma znaczenia. Może to być event, może to być e albo i nawet abc. Chociaż oczywiście te dwie pierwsze mają znacznie więcej sensu ;)

Jeśli masz jeszcze jakieś wątpliwości, to przypomnij sobie, jak działają funkcje.

Np.

function foo(name) {
  console.log(name)
}

foo('bar');
foo('baz');

// alternate version
function foo(param) {
  console.log(param)
}

foo('bar');
foo('baz');

Wywołując funkcję foo, nie przejmujemy się, jak nazywa się parametr, pod który przekażemy daną wartość. Może to być name, a może to być param. Ważna jest jedynie kolejność. Pierwszy parametr zawsze dostanie pierwszą wartość, drugi drugą itd. Jednak nazwa może być dowolna.

Stąd też funkcja callback zawsze dostanie pod pierwszym argumentem informacje o evencie. Nieważne, czy nazywa się on e, event czy abc.

Pytanie 3 (dla ambitnych)

Czy poniższy kod jest poprawny? Co pokaże się w konsoli po jego wykonaniu?

function foo(cb, text) {
  cb(text);
}

function bar(textOne, textTwo) {
  console.log(textOne, textTwo);
}

foo(function(txt) { bar(txt, 'World') }, 'Hello');

To znacznie trudniejszy przykład, spróbuj jednak go rozwikłać.

Kod jest poprawny. Konsola wyświetli napis Hello World. Napis Hello pochodzi z argumentu text, który bar dostaje po wywołaniu cb (czyli funkcji function(txt) { bar(txt, 'World') }). World jest za to dostarczany do bar bezpośrednio.

To bardziej zawiły przykład. Nie oczekujemy, że masz go rozwiązać ot tak. Spróbuj go przeanalizować kilka razy, a w razie wątpliwości poproś o pomoc Mentora. Jeśli uda Ci się go zrozumieć bez dodatkowej pomocy, naprawdę może rozpierać Cię duma.

Magiczne słowo this

W poprzednim module pojawiło się jeszcze jedno tajemnicze słowo – this. Jak zapewne pamiętasz, pojawiało się w funkcjach i najczęściej wskazywało na kliknięty element. Czy zawsze tak jest? Niestety nie. Skąd w takim razie mamy wiedzieć czym będzie this w danej sytuacji?

Wbrew pozorom, to nie będzie aż takie trudne. Możliwości, czym this będzie w danej sytuacji, nie ma wcale aż tak dużo i opierają się ona na kilku prostych zasadach. Znając je, będziesz w stanie zawsze bezboleśnie i z pewnością stwierdzić, czego się spodziewać.

Przedstawimy je za chwilę w odwrotnej kolejności – od najmniej ważnej do tej, która dla silnika JS będzie kluczowa. Każda kolejna zasada będzie miała więc większy priorytet.

Uwaga!

Wszystkie te zasady tyczą się ustalania this w funkcji. W kontekście globalnym (czyli poza jakąkolwiek funkcją) this będzie zawsze równe obiektowi globalnemu, czyli window.

Default rule – Window vs Undefined

Pierwsza z nich jest dość krótka: jeśli skrypt jest wykonany w strict mode, to this w funkcji przyjmuje wartość undefined. Jeśli nie, to przyjmuje wartość obiektu globalnego, czyli obiektu window.

Proste? Proste! Aby to uwiarygodnić, sprawdź dwa poniższe przykłady:

'use strict';

console.log(this);

function foo() {
  console.log(this);
}

foo();

W kontekście globalnym (czyli poza jakąkolwiek funkcją) this to naturalnie obiekt window. W funkcji foo, zgodnie z treścią w ramce powyżej – również będzie to już undefined.

To teraz przykład bez use strict, żeby udowodnić drugą tezę:

console.log(this);

function foo() {
  console.log(this);
}

foo();

Oczywiście this w kontekście globalnym się nie zmieniło. Tak jak mówiliśmy, jest to bowiem zawsze window. Zmienił się jednak this w funkcji foo, który teraz zgodnie z zasadą również wskazuje na window.

Jak widzisz, pierwsza zasada okazała się dość prosta. W takim razie możemy iść dalej.

Implicit binding rule – wywoływanie metody

Druga jest już trochę ciekawsza. Na razie tworzyliśmy głównie bardzo proste obiekty, takie jak np. allTags.

const allTags = {
  code: 1,
  news: 2,
  ...
}

Mogą być one jednak znacznie ciekawsze. Właściwości obiektu nie muszą być bowiem proste. Nie muszą być tylko tekstem, czy liczbą (jak w przykładzie wyżej). Mogą być również tablicą, kolejnym obiektem, czy nawet funkcją!

Spójrz tylko na poniższy przykład:

const JohnDoe = {
  firstName: 'John',
  lastName: 'Doe',
  hobbies: ['sport', 'movies'],
  sayHello: function() {
    console.log('Hello!');
  }
}

Dostęp do właściwości sayHello nie będzie inny niż w przypadku chociażby firstName. Dojdziemy do niej po "kropce".

JohnDoe.sayHello();

Kiedy już to wiemy, to możemy przejść do zasady numer dwa. Jeśli wywołujemy metodę (właściwość, która jest funkcją) jakiegoś obiektu, to wskazuje on właśnie na ten obiekt.

Dwa krótkie przykłady:

const foo = {
  bar: function() {
    console.log(this);
  }
}

foo.bar();

Na co wskaże this? Zgodnie z tą zasadą, na obiekt foo. To jeszcze jeden:

function func() {
  console.log(this);
}

const foo = {
  bar: func
}

foo.bar();

Drugi przykład jest znacznie ciekawszy. Czym będzie this tym razem? Zgodnie z zasadą – również foo.

Metoda bar to tylko referencja do funkcji znanej jako func. Uruchamiając więc foo.bar, tak naprawdę uruchamiamy funkcję:

function() {
  console.log(this);
}

A skoro uruchamiamy ją "na obiekcie" (foo.bar), no to this będzie wskazywać również właśnie na ten obiekt.

Pokazuje to jedną bardzo istotną rzecz: nieważne, gdzie funkcja jest zapisana w kodzie. Ważne gdzie jest wywoływana. Zauważ, że w naszym przykładzie funkcja bar jest tak naprawdę “trzymana” poza obiektem foo. Atrybut bar z tego obiektu jest tylko referencją do niej. Jednak mimo tego, że sama funkcja jest poza tym obiektem, to przy wywołaniu wskazuje właśnie na niego. Czyli jeszcze raz, zapamiętaj – this tyczy się miejsce wywołania funkcji (tzw. "call site"), a nie jej fizycznej pozycji w kodzie.

Żeby zostało Ci to w głowie, spójrz na ostatni przykład:

function func() {
  console.log(this);
}

const obj1 = {
  name: 'object 1',
  bar: func
}

const obj2 = {
  name: 'object 2',
  bar: func
}

obj1.bar(); // this = obj1
obj2.bar(); // this = obj2

Czym będzie this w przypadku wywołania obj1, a czym w przypadku wywołania obj2? Zgodnie z zasadą – obiektem, na którym włączamy tę funkcję. Raz będzie to więc obj1, a za drugim razem obj2. Zatem widzisz, ponownie mimo tego, że kierujemy do jednej i tej samej funkcji i nie zmieniła ona położenia, to this zależnie od miejsca jej wywołania (call site), wskaże na inną wartość.

image

Explicit binding rule – wymuszenie kontekstu

W wielu przypadkach dwie pierwsze zasady mogą Ci wystarczyć, ale znajdą się wciąż takie, w których bez znajomości kolejnych, nie zrozumiemy, co się dzieje.

Spójrz chociażby na ten przykład:

const button = document.querySelector('#btn');

function foo(event) {
  console.log(event, this);
}

button.addEventListener('click', foo);

Załóżmy, że stała button kieruje nas do faktycznie istniejącego przycisku nas stronie. Jak myślisz, co pokazałoby się w konsoli po kliknięciu?

Zapewne wiesz już z poprzednich modułów, że będzie to obiekt z informacjami o evencie (parametr event) oraz referencja do samego buttona (this). Dlaczego jednak w tej sytuacji this jest tym, czym jest? Dlaczego jest buttonem? W końcu żadne z naszych znanych zasad takiego przypadku nie omawiają. Jak widzisz, musimy drążyć dalej.

Aby zrozumieć ten przykład, musimy wrócić do omówienia, jak może być zbudowana funkcja addEventListener

addEventListener: function(eventType, callback) {
  // ...
  const eventObj = { preventDefault: ..., target: ...}
  callback(eventObj)
}

Przypomnijmy, zapewne przyjmuje ona w formie parametrów informacje, na jakie zdarzenie JS ma zwrócić uwagę oraz referencję do funkcji callback, która ma się wykonać w momencie jego wykrycia.

W momencie wykrycia zdarzenia zapewne tworzony jest obiekt ze szczegółowymi informacjami na jego temat, a gdy jest już gotowy, dochodzi do wywołania funkcji callback wraz z przekazaniem tego obiektu poprzez pierwszy parametr.

Taki scenariusz tłumaczy skąd możliwość “odebrania” tego obiektu pod parametrem event w naszym przykładzie.

const button = document.querySelector('#btn');

function foo(event) {
  console.log(event, this);
}

button.addEventListener('click', foo);

No dobrze, ale co z samym elementem this? Dlaczego wskazuje on na button? Spokojnie, zaraz do tego dojdziemy.

Na pewno addEventListener musi wiedzieć, na jaki element powinien zwracać uwagę, obserwować. Skąd? Tu odpowiedź będzie bardzo prosta.

Spójrz na wywołanie tej funkcji w naszym przykładzie:

button.addEventListener('click', foo);

Czy któraś z poznanych Ci już zasad się w tym miejscu sprawdzi? Tak. Zasada Implicit binding rule, czyli jeśli włączamy metodę na obiekcie, to this w kontekście wywołania takiej funkcji wskaże właśnie na ten obiekt. A więc na co wskaże this w kontekście wywołania addEventListener w naszym przypadku? Na przycisk (stała button)! Pamiętaj bowiem, że element DOM to obiekt jak każdy inny.

Dobrze, wiemy już wewnątrz funkcji addEventListener, że this wskaże tu nasz button, ale jak to możliwe, że potem ten this został przekazany dalej do funkcji foo? Jak rozwiązali to twórcy tej funkcji?

Na pewno nasz pomysł jest tutaj zbyt prosty.

addEventListener: function(eventType, callback) {
  const targetElement = this;
  console.log(targetElement); // button
  // ...
  const eventObj = { preventDefault: ..., target: ...}
  callback(eventObj)
}

Wiemy, że wewnątrz funkcji addEventListener mamy dostęp do odpowiedniego this, ale czy wywołanie callback też go dostanie? Nie. Dla wywołania funkcji JS ustala this od nowa. Która ze znanych Ci już zasad byłaby więc brana pod uwagę przy jego ustaleniu dla funkcji callback? Nie jest to funkcja wywoływana na obiekcie, więc zgodnie z hierarchią trafiamy do zasady default rule (domyślnej).

Zakładając, że nie używamy w naszym przykładzie strict mode, zgodnie z zasadą callback (czyli foo) wskaże nam jako this obiekt globalny window.

Wszystko dotychczas jest jasne? addEventListener jest wywoływana na obiekcie DOM, więc this w kontekście wywołania tej metody wskazuje na ten obiekt właśnie – przycisk. Funkcja callback (czyli właściwie foo) nie jest wywoływana na obiekcie, nie mamy również strict mode, więc zgodnie z poznanymi zasadami jej this wskaże na obiekt window.

Pozostaje nam w takim razie jedna kwestia – jak wymusić na foo, żeby pokazywała jako this coś innego, to co chcemy?

Metody call i apply

To jest nasza odpowiedź. Obie powyższe metody pozwalają na wywoływanie funkcji z dowolnymi parametrami i dowolną wartością this. Oznacza to, że możemy wywołać funkcję w taki sposób, że this nie będzie ustalane przez JS-a, tylko przez nas! Różnica między nimi jest tylko taka, że w call parametry wypisujemy po kolei jak przy standardowym wywołaniu:

func.call(thisArg, param1, param2);

Natomiast w przypadku apply parametry funkcji są przekazywane w formie tablicy:

func.call(thisArg, [argsArray]);

W każdym razie obie nadadzą się idealnie, w sytuacji, gdy chcemy wprowadzić do danego kontekstu funkcji własne this. Mając nową wiedzę, możemy łatwo zmodyfikować nasz przykład.

function addEventListener(eventType, callback) {

  const targetElement = this;

  /* ... then when JS observes eventType, it reacts. */

  /* eventData object with details is created and.. */

  callback.call(targetElement, eventData);
}

Metoda call nadal będzie w stanie wywołać funkcję ukrytą pod callback, ale tym razem wartość this w takiej funkcji będzie równa targetElement… czyli tak naprawdę thisowi z kontekstu funkcji addEventListener! Koniec końców funkcja callback przy takim uruchomieniu będzie miała więc faktycznie ten sam this co kontekst funkcji addEventListener. Zatem w końcu, przekazana do nasłuchiwacza foo, pokaże nam, zgodnie z planem, jako this nasz button.

Jak widzisz, nie było to aż takie trudne, ale okazuje się, że bez znajomości tej zasady i wiedzy, że w ogóle można wymusić na kontekście funkcji własne this, zrozumienie, co tu się właściwie stało, nie byłoby możliwe.

Nie oczekujemy, że masz ten przykład zapamiętać. Prawdopodobnie podczas niniejszego kursu, nigdy nie będzie potrzeby skorzystania ze wspomnianych metod. Pokazaliśmy ten przykład głównie dlatego, żeby udowodnić Ci, że za JS-em nie stoi żadna magiczna siła. To wszystko z czegoś wynika. Tak naprawdę wystarczy, że wyniesiesz z tego jedno, iż funkcja callback uruchamia przez nasłuchiwacz domyślnie zawsze wskaże jako this ten element, na którym uruchomiona była sama metoda addEventListener.

Oczywiście nie tylko addEventListener może korzystać z metody .call. To zwykła metoda, sami też możemy jej użyć w naszym kodzie i jako this podstawić cokolwiek tylko chcemy.

Spójrz na ten przykład:

function foo() {
  console.log(this);
}

foo.call({ bar: 'baz' });

Otrzymamy w konsoli:

image

Podsumowując: za pomocą metody call lub apply możemy wymusić dowolną wartość this w danym kontekście, nie zważając nawet, jaka byłaby domyślnie.

Hard binding

Metody call i apply pozwalają wymusić dowolną wartość tylko this przy konkretnym wywołaniu. Na stałe już nie. Istnieje jednak inna metoda, która jest w stanie to zrobić!

To metoda bind. Potrafi ona na podstawie dowolnej funkcji stworzyć nową, która po otrzymaniu na starcie założonego z góry this, zawsze będzie się go trzymać, nieważne, w którym miejscu w kodzie (call site) ją wywołamy. Brzmi nieźle? Jak najbardziej i działa równie prosto.

Spójrz tylko na poniższy przykład:

function foo(param) {
  console.log(this, param);
}

const lockedFoo = foo.bind({ bar: 'baz' });

const obj = {
  foo: lockedFoo
};

lockedFoo('Spam!');
obj.foo('Spam!'); // this = { bar: 'baz' }

I na efekt jego działania:

image

Nieważne, czy włączamy tę funkcję w kontekście globalnym, czy jako metodę obiektu obj, to this jest za każdym razem taki sam. Co więcej, wciąż da się przekazać jakieś parametry!

W jakiej sytuacji bind ma zastosowanie? Kiedy np. this ma ogromne znaczenie dla działania funkcji, ta jest wywoływana w wielu miejscach, a my chcemy mieć pewność co do jego wartości. Wbrew pozorom bind nie jest aż tak często używane, ale na pewno jest to metoda, którą warto znać.

Podsumujmy tę, ale i wcześniejszą zasadę. Za pomocą metody call lub apply możemy wymusić wartość dowolną this przy konkretnym wywołaniu funkcji, nie zważając nawet, jaka byłaby domyślnie. Metoda bind pozwala nam za to stworzyć nową funkcję na bazie już istniejącej, która na zawsze z domysłu będzie miała z góry założoną wartość this, nie zważając na miejsce wykonania.

8.2. OOP, czyli programowanie obiektowe

Słowo kluczowe new

Istnieje jeszcze jedna zasada, która ujawni coś naprawdę niezwykle... ciekawego. Otóż, jeśli wywołując funkcję skorzystasz ze słowa kluczowego new, to JS utworzy nowy pusty obiekt i udostępni go w tej funkcji właśnie pod this. Brzmi to dziwnie, ale naprawdę tak się stanie! Co więcej, ta funkcja z automatu zacznie też taki obiekt zwracać.

No cóż, chyba musisz to zobaczyć na żywo.

Najpierw spróbuj uruchomić następujący przykład:

function foo() {
  this.bar = 'baz';
  console.log(this);
}

foo();

Mamy tutaj jedną funkcję – foo. Po włączeniu powinna ona dodać do this (niezależnie czym on będzie) nową właściwość (bar) o wartości baz, a następnie pokazać jego zawartość.

Przy wywołaniu (foo()) raczej nie masz wątpliwości. Funkcja nie jest włączana na obiekcie ani nie uruchamiamy jej przy użyciu .call, .apply, czy z .bind. W takim razie jedyna z zasad, która ma zastosowanie to pierwsza. Ta, która mówi, że jeśli skrypt nie jest odpalany w strict mode, to funkcja zwróci obiekt window. I tak się faktycznie stanie.

Tym samym nasza funkcja foo przypisze bar do window, a potem pokaże w konsoli właśnie zawartość obiektu globalnego.

Tutaj pewnie wszystko jest jeszcze jasne. Zmodyfikujmy więc nasz kod i dodajmy kolejne wywołanie. Tym razem ze słowem kluczowym new.

function foo() {
  this.bar = 'baz';
  console.log(this);
}

foo();
const obj = new foo();
console.log(obj);

Zajrzyj teraz do konsoli. Okaże się, że pierwsze wywołanie tej funkcji przyniesie takie same rezultaty. To oczywiste. Jednak już drugie... no właśnie...

image

Spójrz tylko. Okazało się, że tym razem this to jakiś obiekt z jedną właściwością bar. Co to oznacza? Wygląda na to, że przy włączeniu funkcji foo, this domyślnie stał się pustym obiektem. Nasza instrukcja this.bar = 'baz' dodała więc do niego nową właściwość bar. Dlatego też konsola jako this pokazała właśnie { bar: 'baz' }.

Wygląda to dziwnie, ale właśnie tak się stało! Co więcej, zwróć uwagę co otrzymaliśmy w konsoli jako obj. Ten sam obiekt! Wygląda więc na to, że mimo tego, że w naszej funkcji nie użyliśmy słowa kluczowego return, to ta i tak coś w takiej sytuacji zwróciła. A konkretnie właśnie ten obiekt, który widzieliśmy też pod this.

Jak możesz sobie to wytłumaczyć? Najprościej zapamiętaj to w taki sposób, że słowo kluczowe new przy wywołaniu funkcji dodaje do niej dwie "niewidoczne" linijki.

function() {
  const this = {};
  ...
  return this;
}

Jeśli tak to sobie to wyobrazimy, to łatwiej możemy zrozumieć, co się właściwie stało przy drugim wywołaniu:

function foo() {
  const this = {}
  this.bar = 'baz';
  console.log(this);
  return this;
}

Spójrz tylko. Wszystko od razu staje się jaśniejsze. Funkcja foo się uruchamia, tworzy nowy pusty obiekt i zapisuję go pod this, następnie dodajemy do niego właściwość bar, pokazujemy w konsoli i zwracamy.

W takiej sytuacji łatwiej możemy też zrozumieć, skąd taka niespodziewana wartość obj.

const obj = new foo();
console.log(obj);

Skoro wiemy, że nasza funkcja foo w takiej sytuacji zwraca po prostu wcześniej utworzony obiekt (a dokładnie referencję do niego), to łatwo możemy zrozumieć, że obj zwyczajnie ją przyjmuje. Tym samym pokazując w konsoli obj, pokazujemy właśnie ten utworzony wcześniej obiekt – { bar: 'baz' }.

Musisz przyznać, że teraz staje się to już trochę bardziej czytelne. Wystarczy więc, że zapamiętasz, iż przy wywołaniu funkcji za pomocą słowa kluczowego new, JS utworzy nowy pusty obiekt i przypisze go do this tej funkcji oraz zadba o to, aby go zwracała.

Interesujący use-case

Takie zachowanie funkcji może wydawać się dziwne, ale... wbrew pozorom daje nam bardzo ciekawą zaletę.

Wyobraź sobie, że mamy trzy bardzo podobne obiekty.

const JohnDoe = { firstName: 'John', lastName: 'Doe', age: 22 };
const AmandaDoe = { firstName: 'Amanda', lastName: 'Doe', age: 30 };
const ThomasJefferson = { firstName: 'Thomas', lastName: 'Jefferson', age: 25 };

Wszystkie obiekty są identyczne, co do swojej budowy. Mimo to jednak, deklarujemy je jako trzy osobne byty. Oczywiście, my jesteśmy w stanie szybko zauważyć, że to bardzo podobne obiekty, ale JS wcale ich ze sobą nie powiąże. W takiej sytuacji, gdybyśmy np. pomylili się i w jednym z obiektów zamiast właściwości firstName wpisali firtName, to JS wcale nie podniesie alarmu. Dla niego nie będzie problemem to, że jakiś obiekt ma właściwość firstName, a inny firtName, właśnie dlatego, że są to dwa niepowiązane ze sobą obiekty.

Znając jednak działanie słowa kluczowego new, możemy tę sytuację zmienić. Możemy stworzyć funkcję, która będzie działać jako swego rodzaju konstruktor. Taki, który będzie generował obiekty o założonym schemacie. W jaki sposób?

Bardzo prosto:

function Person(firstName, lastName, age) {
  this.firstName = firstName;
  this.lastName = lastName;
  this.age = age;
}

const JohnDoe = new Person('John', 'Doe', 22);
const AmandaDoe = new Person('Amanda', 'Doe', 30);
const ThomasJefferson = new Person('Thomas', 'Jefferson', 25);

Co tu się właściwie dzieje? Trzy razy włączamy funkcję Person przy użyciu słowa kluczowego new. Za każdym razem funkcja ta tworzy pod this zupełnie nowy obiekt, dodaje do niego trzy właściwości o wartościach zgodnych z argumentami, a na koniec zwraca referencje do niego. Tym samym JohnDoe staje się referencją do obiektu { firstName: 'John', lastName: 'Doe', age: 22 }, AmandaDoe do obiektu { firstName: 'Amanda', lastName: 'Doe', age: 30 }, a ThomasJefferson do { firstName: 'Thomas', lastName: 'Jefferson', age: 25 }.

Czyli koniec końców... otrzymamy dokładnie te same obiekty! Tym razem jednak są one tworzone przez jedną funkcję, która zawsze użyje tych samych nazw właściwości. Nie ma takich opcji, że raz będzie to firstName, a innym razem firtName. Można powiedzieć, że teraz tworzymy obiekty w oparciu o jeden i ten sam schemat. A dodatkowo nasze zmiany spowodowały również lekkie skrócenie kodu.

Jeśli działanie tego kodu nie do końca jest dla Ciebie jasne, to pamiętaj, że najprościej wyobrazić sobie to tak, że JS dodaje przy użyciu new do takiej funkcji dwie niewidoczne linijki.

function Person(firstName, lastName, age) {
  const this = {}; // it's hidden from us
  this.firstName = firstName;
  this.lastName = lastName;
  this.age = age;
  return this; // it's hidden from us
}

I jak? Pewnie teraz ma to już większy sens, prawda?

Podsumowanie this

Podsumujmy teraz wszystko co wiemy o słowie kluczowym this.

W przypadku kontekstu globalnego (czyli poza funkcjami) wartość this jest zawsze równa referencji do obiektu globalnego, czyli obiektu window.

W przypadku funkcji stosuje się odpowiednie zasady:

  1. Jeśli użyto przed funkcją/konstruktorem klasy słowa kluczowego new, to this w tej funkcji zawsze będzie równe referencji do nowo utworzonego obiektu.
  2. Jeśli funkcja została utworzona przy pomocy metody bind, to jej wartość this będzie zawsze równa temu, co zostało ustalone przy jej kreacji. Podobnie jak w przypadku call oraz apply, kontekst (this) jest przypisywany z pierwszego argumentu funkcji.
  3. Jeśli funkcja jest wywoływana za pomocą metody call lub apply, to this w kontekście wywoływanej funkcji zawsze będzie równe wartości podanej jako pierwszy parametr tej metody.
  4. Jeśli funkcja została wywołana “na obiekcie”, to this będzie wskazywał właśnie na ten obiekt.
  5. Domyślnie, jeśli żadna z wcześniejszych zasad nie dotyczy wywołania danej funkcji, this będzie wskazywać na obiekt globalny (window) lub w przypadku użycia strict mode wartość undefined.

Kolejność jest tu istotna. Zastanawiając się nad wartością this w danym kontekście, sprawdzaj, czy pasują do niego poszczególne zasady od góry do dołu. Przykładowo, dopiero jeśli ustalisz, że w danym wywołaniu nie ma mowy o słowie kluczowym new, należy zastanawiać się nad zasadą numer 2. Tak samo dopiero jeśli nie ma dalej słowa kluczowego bind, to warto sprawdzić zasadę nr 3 itd.

Jak widzisz, to nie było takie trudne. Mając taką krótką listę zasad, już nigdy nie będziesz mieć problemu z ustaleniem wartości this w danej funkcji. Nie staraj się nauczyć tego koniecznie na pamięć. W razie potrzeby zawsze możesz wrócić do tej listy.

Podsumowanie

Zdajemy sobie sprawę, że ten submoduł mógł Cię mocno wymęczyć. Prawdopodobnie wszystko, co w nim poznaliśmy, udało Ci się, koniec końców, przyswoić, jednak wątpliwe, że czujesz się w tych tematach bardzo pewnie. Raczej jeszcze nie do końca. Nie musisz się jednak tym martwić. Tak naprawdę wcale jeszcze tego od Ciebie nie oczekujemy.

Co musisz zapamiętać po tym submodule? Informację, na czym polega referencja, ideę funkcji callback, fakt że jest kilka zasad, które kierują ustaleniem this w funkcji oraz że słowo new użyte na funkcji tworzy i zwraca nowy obiekt. Tylko tyle. To jak brzmią dokładnie zasady ustalania this, jak może wyglądać funkcja addEventListener, czy jak radzić sobie w bardziej skomplikowanych przykładach, nie jest teraz zbyt istotne. W razie potrzeby, po prostu możesz wrócić do treści tego submodułu. Nie staraj się zapamiętywać wszystkiego na siłę. Podczas kolejnych submodułów, ciągle będziemy do tych tematów jeszcze wracać i o nich przypominać. Prędzej czy później, wszystko "wejdzie" Ci więc do głowy samoistnie.

Oczekujemy tylko tego, że masz mieć już "jakieś" pojęcie na temat tego, co przedstawiliśmy. Tak, żeby podczas dalszej części modułu, np. this czy referencje nie były dla Ciebie zupełną nowością. Szczegóły zawsze przypomnimy.

Wyposażeni w nową wiedzę, w końcu na dobre możemy przejść do tematu programowania obiektowego.

Czym różni się OOP od programowania funkcyjnego?

Do tej pory stosowaliśmy programowanie funkcyjne. Oznacza to, że cały kod naszej aplikacji dzieliliśmy na funkcje. Mieliśmy z góry zaplanowane, co dana funkcja ma zrobić – np. wygenerować listę linków, albo zwrócić wynik jakichś obliczeń.

Dzięki funkcjom mogliśmy wywoływać ten sam kod wiele razy, z różnymi argumentami. Pozwoliło nam to również trochę uporządkować kod naszej aplikacji. No właśnie – trochę. Utrzymanie porządku w podejściu funkcyjnym jest dość trudne. Łatwiej nam będzie zadbać o przejrzystość kodu w OOP.

Zacznijmy może od tego, że w OOP też używamy funkcji i są one bardzo ważne, ale nie są najwyższym poziomem organizacji kodu – ta rola przypada klasom i instancjom.

Klasa jest wzorcem/schematem – definicją tego, jak będą "wyglądały" instancje tej klasy. A instancje są obiektami stworzonymi wedle tego wzorca. To trochę jak w naszym przykładzie z poprzedniego submodułu. Mieliśmy "schemat", czyli funkcję Person i instancje, czyli obiekty, które na bazie tego schematu utworzyliśmy.

Gdzie są obiekty w OOP?

W JS-ie łatwo się pogubić, jeśli chodzi o określenie "obiekt", bo bardzo dużo rzeczy jest obiektem. Wynika to z ogólnej definicji obiektu – jest to zbiór właściwości i metod. Właściwościami są wartości przechowywane w obiekcie, a metodami – funkcje.

Formalnie rzecz biorąc, instancje są obiektami, więc będziemy tych określeń używać naprzemiennie. Pamiętaj jednak, że nie każdy obiekt, musi być instancją jakiejś klasy. Istnieją też inne proste obiekty – np. tworzone za pomocą nawiasów klamrowych { }.

Np. obiekt const params = { min: 0, max: 99999 }; z poprzedniego modułu wcale nie jest instancją żadnej klasy. Tym samym instancje możemy nazywać obiektami, ale nie wszystkie obiekty można określić jako instancje. Spokojnie, zaraz trochę się to rozjaśni :)

Załóżmy, że tworzymy bazę pracowników. Czy chcielibyśmy, za każdy razem tworzyć zupełnie nowy obiekt? Nie. Raczej wolelibyśmy zdefiniować jeden schemat – klasę Employee (pracownik), która będzie szablonem dla każdego obiektu reprezentującego pracownika. W tej klasie ustalamy, że pracownik będzie miał imię, nazwisko, stanowisko czy wysokość pensji. Podobnie w tej klasie ustalimy, że pracownik ma mieć metodę raise (podwyżka), która podnosi jego pensję np. o 5%.

Na poziomie klasy nie wiemy, jaka będzie pensja poszczególnego pracownika. Metoda raise musi tylko wiedzieć, że pracownik będzie miał przypisaną jakąś pensję, i że ma ją podnieść o 5%.

Dopiero po napisaniu klasy (schematu) możemy tworzyć jej instancje, czyli poszczególnych pracowników. Wtedy podajemy, jak nazywa się dany pracownik, jakie ma stanowisko i jaką pensję. Po jakimś czasie (kiedy pracownik sobie na to zasłuży) możemy też wywołać jego metodę raise – nie musieliśmy tej metody pisać specjalnie dla niego, ponieważ odziedziczył ją z klasy Employee.

Zanim przejdziemy dalej, zapoznaj się ze składnią klasy i instancji. Nie musisz wszystkiego rozumieć ani tym bardziej zapamiętywać – warto jednak już teraz przyjrzeć się ich składni. Dzięki temu łatwiej będzie Ci zrozumieć, czym jest klasa.

W tym momencie warto również wrócić na chwilę do poprzedniego submodułu i przykładu z funkcją Person. Zauważ, że tworzenia nowej instancji bardzo mocno przypomina to, jak tworzyliśmy obiekty właśnie przy użyciu tejże funkcji. Nawet korzystamy z tego samego słowa kluczowego – new.

Skąd takie podobieństwo? Stąd, że tak naprawdę w JS, w odróżnieniu od innych popularnych języków programowania, nie ma prawdziwego mechanizmu tworzenia klas. Tak naprawdę, słowo kluczowe class i zapisy, które widzisz w dokumentacji, to tylko "lukier składniowy". Oznacza to tyle, że jesteśmy w stanie pisać kod trochę w bardziej czytelny sposób, ale, koniec końców, jest on konwertowany do innej bardziej skomplikowanej postaci. Dokładnie tak jest też w naszej sytuacji.

Tak naprawdę poniższy przykład:

class Employee{
  constructor(name, age, yearlySalary){
    const thisEmployee = this;

    thisEmployee.name = name;
    thisEmployee.age = age;
    thisEmployee.yearlySalary = yearlySalary;

    thisEmployee.calculateMonthlySalary();
  }

  calculateMonthlySalary(){
    const thisEmployee = this;

    thisEmployee.monthlySalary = thisEmployee.yearlySalary / 12;
  }

  showDetails(){
    const thisEmployee = this;

    console.log(thisEmployee.name, thisEmployee.age, thisEmployee.monthlySalary);
  }
}

const john = new Employee('John Doe', 20, 12000);

...jest konwertowany "pod maską" na:

function Employee(name, age, yearlySalary) {
    const thisEmployee = this;

    thisEmployee.name = name;
    thisEmployee.age = age;
    thisEmployee.yearlySalary = yearlySalary;

    thisEmployee.calculateMonthlySalary();
  }
}

Employee.prototype.calculateMonthlySalary = function() {
  const thisEmployee = this;

  thisEmployee.monthlySalary = thisEmployee.yearlySalary / 12;
}

Employee.prototype.showDetails = function() {
  const thisEmployee = this;

  console.log(thisEmployee.name, thisEmployee.age, thisEmployee.monthlySalary);
}

const john = new Employee('John Doe', 20, 12000);

Obie wersje dałyby więc dokładnie taki sam rezultat. Chyba jednak nie trudno Ci zrozumieć, po co ta "maskarada". Zauważ, że w oryginalnym zapisie, całość wygląda znacznie mniej czytelnie. Musimy zastanawiać się, czy function jest w danej sytuacji funkcją, czy może ma udawać "klasę"? Pojawia się też tajemnicza właściwość prototype. Delikatny lukier składniowy sporo nam tutaj uczytelnia. Upodobania przy tym składnię JS-a do tego, jak klasy czy metody (funkcje w klasach) deklaruje się w innych językach. My do końca kursu będziemy już zawsze używać właśnie tej "polukrowanej" składni. Nie musisz więc zaprzątać sobie głowy tym, jak ten kod będzie wyglądać "pod maską".

Podsumujmy teraz krótko naszą wiedzę.

Metafora – klasy i instancje

Najłatwiej będzie Ci zapamiętać te zagadnienia na przykładzie ciasta.

Klasa jest jak przepis na ciasto: mówi, jakie składniki i naczynia będą Ci potrzebne, a także jakie operacje będzie trzeba wykonać.

Instancja jest jak ciasto upieczone na podstawie tego przepisu – jest obiektem stworzonym wedle szablonu, jakim jest przepis.

Ważne, żeby nie mylić tych rzeczy ze sobą – po obiedzie nie częstujemy przepisem, a do regału nie odkładamy ciasta. ;)

Podobnie jak deklaracja funkcji nie wie, jakie będą argumenty, tak klasa nie wie, jakie dane będą przekazane instancji tej klasy. W trakcie pisania klasy możemy jednak zaplanować, że będziemy chcieli, aby to było np. imię i nazwisko pracownika.

W wielkim skrócie, na tym polega programowanie obiektowe. Będziemy tworzyć klasy, aby służyły za wzorzec wielu instancjom. Każda klasa jest zamkniętym ekosystemem, dzięki czemu łatwiej będzie nam odnaleźć się w kodzie, i bez problemu tworzyć coraz bardziej skomplikowane projekty. Pierwszy z nich zaczynamy już za chwilę...

8.3. Otwieramy pizzerię!

Projektem, który za chwilę rozpoczniemy, będzie strona pizzerii! To nie byle jaka strona – umożliwi złożenie zamówienia z dostawą!

Specyfikacja projektu

Chcemy, aby nasza strona pizzerii miała następujące funkcjonalności:

  • menu ma wyświetlać listę produktów,
  • każdy produkt ma rozwijany panel z opisem i opcjami zamówienia,
  • każdy produkt może mieć dowolną liczbę opcji w postaci checkboxów, radio-buttonów, oraz selectów,
  • każda opcja produktu może zmieniać jego cenę,
  • można zamówić kilka produktów tego samego typu,
  • zamawiane produkty mają trafiać do koszyka,
  • koszyk ma podawać cenę zamówienia z uwzględnieniem stałego kosztu dowozu,
  • dla każdej pozycji w koszyku ma być możliwość usunięcia, zmiany ilości oraz edycji opcji tej pozycji,
  • lista projektów nie będzie zawarta w plikach JS ani HTML, ale będzie pobierana z serwera już po wczytaniu strony,
  • składane zamówienia będą zapisywane na serwerze,
  • strona ma być publicznie dostępna w internecie.

Na tym zakończymy projekt – nie będziemy implementować kosztów dowozu zależnych od odległości ani osobnej strony checkout z formularzem na dane osobowe, adres czy wybór formy płatności. Pozostawiamy te aspekty projektu Tobie – po kursie możesz wrócić do projektu i uzupełnić go. Nawet bez nich będzie to wyzwanie, ponieważ mamy przed sobą naukę nowego podejścia do programowania. Dlatego wykonanie całej funkcjonalności tego projektu zajmie nam dwa tygodnie.

Prawie zapomnieliśmy o jeszcze jednej funkcjonalności naszej strony – każdy produkt może mieć swoją ilustrację, która może zmieniać się w zależności od wybranych opcji! Dzięki temu użytkownik będzie widział rysunek dokładnie takiej pizzy lub sałatki, jaką chce zamówić!

image

Skutkiem ubocznym wyboru takiej tematyki projektu może być wzmożony apetyt, za co serdecznie przepraszamy. ;)

Uruchomienie projektu

Zależy nam, żebyśmy w tym module mogli skupić się na programowaniu obiektowym. Dlatego przygotowaliśmy dla Ciebie początkowe pliki projektu. Znajdziesz w nich trochę pomocnych funkcji, ale również zupełnie nowy task runner.

Zacznij od stworzenia nowego repozytorium na GitHubie – może nazywać się np. project-pizzeria. Sklonuj to repozytorium, a następnie rozpakuj do niego pliki projektu, które przygotowaliśmy. Zapisz commit i wypchnij go na zdalne repozytorium.

Pobierz pliki projektu

Na razie niczego nie zmieniaj w tych plikach – zaczniemy od omówienia stanu początkowego.

Początkowy wygląd strony

Nie zdziw się, że na początku strona będzie wyglądała na pustą. W menu nie będzie żadnych pozycji, ponieważ za chwilę będziemy wstawiać je za pomocą szablonu.

Struktura plików

Pierwszą nowością jest struktura plików. W tym projekcie zastosujemy bardzo często spotykaną strukturę, w której pliki tworzone przez nas będą umieszczone w katalogu src – jest to skrót od słowa source, czyli źródło. Dlatego też pliki w tym folderze będziemy nazywać plikami źródłowymi.

W katalogu src zobaczysz znajomą strukturę – tu znajduje się plik index.html oraz katalogi sass, js i images.

W parze z src będziemy mieli katalog dist, czyli skrót od distribution. Zostanie on utworzony po uruchomieniu task runnera i będzie zawierał automatycznie generowane pliki strony na podstawie źródłowych. W naszym projekcie tylko w katalogu src/sass będziemy mieć pliki .scss, z których będą generowane pliki .css w dist/css.

Pamiętaj, aby nie edytować ani dodawać żadnych plików do dist – ten katalog będzie regularnie czyszczony z całej zawartości. Wszystkie pliki znajdujące się w nim będą kasowane, a na ich miejsce będą tworzone nowe. Będzie to rolą naszego task runnera.

Co więcej, katalog dist nie będzie dodawany do repozytorium. Będzie służył nam tylko do lokalnego podglądu strony. W następnym module dowiemy się też w jaki sposób publikować stronę w internecie – tam również będzie dostępna ta sama zawartość, którą znajdziemy w dist.

Plik src/index.html

W tym pliku znajdziesz podstawową strukturę HTML. Najważniejsze elementy w tym pliku, to:

  • odwołania do plików .css i .js,
  • dwa puste divy (#menu i #cart), które mają pozostać puste,
  • szablon Handlebars produktu, który omówimy sobie nieco później.

Plik jest gotowy do pracy i na razie nie będziemy w nim niczego zmieniać.

Katalog src/sass

W ramach tego projektu mało będziemy zajmować się stylami strony – chyba że będziesz mieć ochotę przerobić cały jej wygląd. Warto jednak zatrzymać się na chwilę przy tym katalogu i przestudiować jego zawartość.

Głównym plikiem jest jak zwykle style.scss. Tym razem jednak znajdziesz w nim tylko deklaracje importujące zawartość innych plików. Zwróć uwagę, że nazwy pozostałych plików zaczynają się od podkreślenia _ – dzięki temu Sass będzie wiedział, aby nie generować z nich plików .css.

Zamiast tego, ich zawartość będzie wykorzystana tam, gdzie zostały zaimportowane – czyli w style.css zamiast deklaracji @import znajdziesz zawartość poszczególnych plików _*.scss.

Takie podejście jest bardzo częste w projektach. Im większy projekt, tym bardziej da się odczuć korzyści płynące z podziału stylów na mniejsze pliki. Nie musisz już przewijać setek linii, aby znaleźć deklaracje zmiennych – doskonale wiesz, że znajdują się w pliku _settings.scss i możesz zawsze do nich zajrzeć. Dzięki temu łatwiej jest zachować porządek w stylach i myśleć o nich jako o komponentach.

Poświęć 10-15 minut na przeglądanie tych plików. Warto poznać zarówno strukturę plików .scss, jak i stylów w tych plikach. Jest to świetny przykład dobrej organizacji kodu i podejścia komponentowego, które jest podstawą rozwijalności projektu.

Pliki JS

Przyjmy się teraz samym plikom JS, z którymi za chwilę zaczniemy pracować.

Plik src/js/functions.js

W tym pliku umieściliśmy kilka funkcji, które będą nam potrzebne w trakcie realizacji projektu. Wszystkie znajdują się w obiekcie utils – to skrót od słowa utilities, czyli narzędzia.

Komentarze na początku pliku mogą przykuć Twoją uwagę – są to deklaracje dla ESLinta, aby nie wykrywał błędów:

  • w przypadku Handlebars, ustawiamy tę zmienną jako globalną – inaczej ESLint twierdziłby, że używamy zmiennej, która nie została zadeklarowana; i faktycznie, w tym pliku nie jest zadeklarowana, pochodzi z innego pliku;
  • w przypadku deklaracji stałej utils, ESLint informowałby o tym, że deklarujemy stałą, która nie została nigdzie wykorzystana; z tego względu powiedzieliśmy mu, aby w tej linii zignorował błąd tego typu.

Poszczególne metody utils będziemy omawiać przy ich wykorzystaniu.

Plik src/js/data.js

W tym pliku znajdziesz obiekt dataSource, również z komentarzem dla ESLinta. W tym obiekcie znajduje się cała konfiguracja produktów, które będzie oferować nasza pizzeria. Są to dane, które wkrótce wykorzystamy już w naszej aplikacji. Np. w celu wygenerowania zawartości menu produktów.

Omówimy to sobie szczegółowo nieco później – na razie tylko zapoznajemy się ze strukturą plików.

Plik src/js/script.js

To plik, w którym będziemy robić najwięcej zmian, i to już za chwilę! Zapoznajmy się najpierw z początkową zawartością tego pliku.

Na samym początku możesz zauważyć deklaracje globalnych zmiennych dla ESLinta, oraz komentarz wyłączający wykrywanie błędu w tej linii. Jak wspomnieliśmy wcześniej, są one potrzebne, aby ESLint nie zgłaszał błędów dotyczących np. użycia Handlebars czy utils, zdefiniowanych w innych plikach.

Następnym elementem w tym pliku jest otwarcie nawiasu klamrowego. Jak zapewne pamiętasz, zakresem zmiennych let i stałych const jest blok kodu zamknięty w te nawiasy. Dzięki temu zmienne i stałe z naszego skryptu nie będą dostępne w innych plikach. Upewniamy się w ten sposób, że żaden inny skrypt nie nadpisze naszych zmiennych i stałych.

Zauważ, że nie mogliśmy zastosować tego samego podejścia w plikach functions.js i data.js, ponieważ obiekty tworzone w tych plikach będą nam potrzebne w script.js.

Wróćmy jednak do pliku script.js – dalej znajdziesz w nim:

  • select – obiekt zawierający selektory, które będą nam potrzebne w tym module,
  • classNames – nazwy klas, którymi nasz skrypt będzie manipulował (nadawał i usuwał),
  • settings – ustawienia naszego skryptu, wszystkie wartości, które wygodniej będzie zmieniać w jednym miejscu,
  • templates – szablony Handlebars, do których wykorzystujemy selektory z obiektu select,
  • app – obiekt, który pomoże nam w organizacji kodu naszej aplikacji,
  • app.init(); – wywołanie metody, która będzie uruchamiać wszystkie pozostałe komponenty strony.

Dzięki takiej organizacji pliku będzie nam łatwiej zachować porządek. Z założenia, jedynym wywołaniem (uruchomieniem) funkcji poza app ma być app.init(). To ta metoda uruchomi kolejne, które uruchomią kolejne, etc.

To podejście również spotkasz często w innych projektach. Dzięki temu, że uruchamiamy jedną funkcję, łatwo jest sprawdzić, co ona robi i w jakiej kolejności.

Metoda app.init musi być możliwie krótka, więc będzie tylko uruchamiać inne metody z app. W ten sposób stanie się swoistą "listą treści" naszego skryptu. Na razie zawiera kilka linii z console.log, które pomogą nam wygodniej przeglądać dane zapisane w obiektach.

Będziemy się też starać, aby pozostałe metody w app były również możliwie krótkie. Będą one mocno polegały na klasach, które stworzymy w ramach podejścia obiektowego. Tak, że obiekt app będziemy mogli traktować jako swego rodzaju inicjator. Jego rolą będzie tworzenie nowych instancji i ich wykorzystywanie, ale konkretna logika aplikacji będzie ukryta już właśnie w klasach. Bardzo pomoże to nam w uporządkowaniu kodu. Za chwilę zobaczysz to w praktyce.

Task runner i lintery

Jak wspomnieliśmy mamy w tym projekcie nowy task runner. Jest on rozwinięciem tego, z którego korzystaliśmy wcześniej. Uwzględnia jednak strukturę z katalogami src i dist. Zawiera także konfigurację dla ESLinta i StyleLinta.

Uruchamiamy projekt jak zwykle – jednorazowo npm install zainstaluje wszystkie niezbędne pakiety, następnie npm run watch będzie uruchamiał nasz task runner i podgląd projektu.

Pamiętaj, aby codziennie uruchamiać testy, aby nie gromadzić błędów formatowania w plikach! Możesz to zrobić wyłączając task runner i ponownie uruchomiając npm run watch. Alternatywnie, możesz w osobnym oknie terminala uruchomić npm run test, aby wyświetlić wynik testów, nawet podczas działania task runnera.

No, ale wystarczy już przeglądania plików! Uruchom task runnera i zaczynamy!

8.4. Tworzymy pierwszą klasę

Wiemy już trochę o podejściu obiektowym – czas zacząć je stosować!

Naszą pierwszą klasą (schematem) będzie Product. Każdy produkt w menu naszej pizzerii będzie instancją tej klasy. Będzie ona odpowiedzialna za:

  • dodanie produktu do menu na stronie, wykorzystując szablon Handlebars,
  • uruchomienie akordeonu, czyli funkcjonalności pokazywanie i ukrywanie opcji produktu,
  • obliczanie ceny produktu z wybranymi opcjami.

Nie przejmuj się, jeśli nie masz pomysłu, jak się do tego zabrać – zaczniemy od podstaw i krok po kroku stworzymy te funkcjonalności.

Tworzenie klasy

Zaczynamy od stworzenia pustej klasy Product, która zawiera tylko konstruktor wyświetlający wiadomość w konsoli.

Pamiętaj, żeby mieć pod ręką otwarte informacje o składni klasy i instancji. Pomogą Ci lepiej zrozumieć, co robi nasz kod JS.

W pliku src/js/script.js, przed deklaracją app wstaw ten fragment kodu:

To na razie cała deklaracja klasy, która zawiera prosty konstruktor. Pewnie już wiesz, czym jest konstruktor (z naszego poradnika), ale przypomnimy – to specjalna metoda, która uruchomi się przy tworzeniu każdej instancji.

Tworzenie pierwszej instancji

Klasa to tylko schemat. Aby sprawdzić, jak działa, musimy stworzyć pierwszą instancję. Od razu przygotujemy sobie również metodę obiektu app, która będzie tworzyć instancje klasy Product.

Dodaj deklarację metody app.initMenu przed app.init, i umieść w niej ten kod:

Następnie na końcu app.init dodaj linię:

Teraz Twój obiekt app powinien wyglądać tak:

Zauważ, że oczekujemy iż thisApp (a więc this) ma wskazywać w metodzie init na cały obiekt app. Czy słusznie? Jak najbardziej! Metoda init była uruchamiana w taki sposób – app.init, a więc na obiekcie app. Dlatego też zgodnie z zasadą "Implicit binding rule" wskaże właśnie na app.

Sprawdź komunikaty, które teraz wyświetlają się w konsoli. Powinny pokazać się dwie nowe linie:

new Product: Product {}
testProduct: Product {}

Pierwsza z nich została wyświetlona przez konstruktor klasy, a druga w metodzie app.initMenu. Obie informują nas, że wyświetlają obiekt klasy Product, który nie ma żadnych właściwości.

Wygląda na to, że wszystko działa poprawnie i stworzyliśmy naszą pierwszą instancję klasy! Na razie nie robi ona zbyt wiele, ale zaraz to się zmieni!

Instancja dla każdego produktu

Jak zapewne pamiętasz, definicje naszych produktów zapisaliśmy w obiekcie dataSource w pliku data.js. Możesz otworzyć ten plik, żeby przypomnieć sobie jego zawartość, ale nie musisz w nim niczego zmieniać.

Chcemy, by nasza aplikacja korzystała z tego źródła danych, ale w przyszłości dane będą wczytywane z serwera. Stwórzmy więc od razu metodę app.initData. Na razie bardzo prostą, będzie tylko przygotowywać dostęp do danych z obiektu dataSource. W przyszłości jednak mocno ją zmodyfikujemy, tak aby dane były pobierane z serwera.

Nad deklaracją metody app.init wstaw ten kod:

Tak jak mówiliśmy, na razie przygotowujemy tylko "wygodniejszy" dostęp do danych, przypisując referencję do nich pod właściwość data. Dlaczego referencję? Bo jak już zapewne pamiętasz, kiedy JS widzi próbę przypisania do zmiennej czy właściwości złożonego obiektu, to zawsze domyślnie daje nam właśnie tylko adres (referencję) do oryginalnego obiektu. Tym samym thisApp.data to tylko referencja do tych samych danych, do których kieruje też stała dataSource.

A w app.init, tuż nad thisApp.initMenu(); dodaj:

Teraz przenosimy się do metody app.initMenu – zaczniemy od sprawdzenia, czy dane są gotowe do użycia. Na początku tej metody dodaj:

Sprawdź, czy w konsoli wyświetla się thisApp.data z zawartością. Jeśli nie, sprawdź poprzednie kroki – przede wszystkim czy w app.init metoda thisApp.initData(); jest uruchamiana przed thisApp.initMenu();.

Kiedy sprawdzisz w konsoli zawartość thisApp.data, zobaczysz, że znajduje się w nim obiekt products, który zawiera poszczególne produkty. Stworzymy więc pętlę for...in, iterującą po obiekcie thisApp.data.products.

W tej pętli będziemy tworzyć nową instancję dla każdego produktu. Nie będziemy tych instancji zapisywać do żadnej stałej czy zmiennej, ponieważ nie mamy takiej potrzeby – na razie w app nie potrzebujemy mieć do nich dostępu.

Zastąp zawartość metody app.initMenu następującym kodem:

Teraz w konsoli zobaczysz cztery linie:

new Product: Product {}
new Product: Product {}
new Product: Product {}
new Product: Product {}

To nasze cztery produkty! Te linie są wyświetlane przez konstruktor przy tworzeniu każdej instancji.

Zauważ, że tworząc nową instancję, przekazujemy do konstruktora aż dwa argumenty. Jako pierwszy chcemy przekazać productData. Czym jest ona w naszej sytuacji? Pętla for...in przechodzi po właściwościach obiektu i pod zmienną przechowuje zawsze tylko i wyłącznie nazwę aktualnie "obsługiwanej" właściwości.

Np.

const obj = {
  firstName: 'John',
  lastName: 'Doe'
}

for(let param in obj) {
  console.log(param);
}

...pokaże nam w konsoli tylko nazwy właściwości, a więc firstName i lastName. Pętla nie przejmie się ich wartościami. Możemy się jednak do nich dostać za pomocą składni obiekt[nazwa-parametru]. Jeśli jej nie pamiętasz, to zerknij szybko do dokumentacji.

const obj = {
  firstName: 'John',
  lastName: 'Doe'
}

for(let param in obj) {
  console.log(param, obj[param]);
}

Dokładnie ten sam zamysł wykorzystujemy w naszej pętli for, aby przekazać do konstruktora klasy nie tylko nazwę właściwości (a więc nazwę produktu), ale i też obiekt, który się pod nią kryje.

Czyli, koniec końców przekazujemy np. następujące argumenty. Jako pierwszy nazwę właściwości, a więc cake, a jako drugi obiekt:

{
  class: 'small',
  name: 'Zio Stefano\'s Doughnut',
  price: 9,
  description: 'Treat yourself with this soft, freshly baked cookie. The recipe has been handed down from generation to generation in our family and it has won us several first place prizes in local competitions.',
  images: [
    '<img class="active" src="images/doughnut.svg">',
  ],
},

Analogicznie tworzymy też oczywiście w tej pętli instancje pozostałych produktów. A więc innym razem przekazujemy pizza jako pierwszy argument, a jako drugi zawartość obiektu, który pod tą właściwością się kryje. Jeszcze innym razem breakfast itd.

Zapisywanie argumentów konstruktora

Nasze instancje na razie są jeszcze puste. Co prawda, w pętli przekazaliśmy argumenty konstruktorowi, ale ten... jeszcze na nie oczekuje. Musimy więc zmienić konstruktor w klasie Product, aby zaczął z nich korzystać.

Zaczynamy od nazwania argumentów, które otrzymuje konstruktor. W tym celu zmień linię ze słowem constructor na następującą:

Jak widzisz, argumenty konstruktora deklarujemy w ten sam sposób, w jaki deklarowaliśmy argumenty funkcji – co ma sens, bo konstruktor jest po prostu funkcją.

Jeśli teraz zapiszesz plik i zajrzysz do konsoli, zobaczysz, że nic się jeszcze nie zmieniło. Nasze instancje nadal są pustymi obiektami. Aby to zmienić, musimy zapisać wartości naszych argumentów do właściwości instancji.

Jak możemy to zrobić? Wystarczy skorzystać z this (lub thisProduct, które też prowadzi do tego samego obiektu). Jak zapewne pamiętasz, this jest właśnie odnośnikiem do obiektu, który jest utworzony przez klasę podczas inicjacji, a więc w momencie uruchomienia instrukcji new Product. Zapisując właściwości do thisProduct, przypiszemy je więc po prostu do danej instancji.

thisProduct.id = id;
thisProduct.data = data;

Teraz zmieniło się coś w konsoli! Każda z instancji ma swoje id oraz obiekt data, zawierający wszystkie właściwości tego produktu! Dopiero teraz możesz się przekonać, że każdy z tych produktów jest inny.

Zwróć uwagę, że zmianę wprowadziliśmy tylko w klasie, czyli we wzorcu, wedle którego jest tworzona każda instancja. Dzięki temu każdy produkt zachowuje się tak samo, ma tyle samo właściwości, a za chwilę będzie miał też wspólne metody!

Renderowanie produktu

Stworzymy teraz metodę renderInMenu, która będzie renderować – czyli tworzyć – nasze produkty na stronie.

W deklaracji klasy Product, pod konstruktorem, dodaj nową metodę:

Natomiast w konstruktorze, nad console.log, dodaj linię:

Zadba ona o to, żeby nasz konstruktor uruchomił tę funkcję od razu po utworzeniu instancji.

Teraz musimy przygotować algorytm metody renderInMenu. Ta metoda ma za zadanie:

  • wygenerować kod HTML pojedynczego produktu,
  • stworzyć element DOM na podstawie tego kodu produktu,
  • znaleźć na stronie kontener menu,
  • wstawić stworzony element DOM do znalezionego kontenera menu.

Po zapisaniu tego algorytmu nasza klasa Product powinna wyglądać tak:

Krok 1 – generowanie HTML

Za chwilę omówimy sobie nasz szablon pojedynczego produktu dokładniej, ale najpierw chcemy go użyć. Będzie nam łatwiej zrozumieć jego działanie, patrząc na wygenerowane elementy na stronie.

Spójrz na szczyt pliku, na obiekt select. Znajdziesz w nim obiekt templateOf, a w nim – właściwość menuProduct. Zawiera ona selektor do naszego szablonu produktu.

Wykorzystujemy ten selektor nieco niżej, w obiekcie templates, w którym metoda menuProduct jest tworzona za pomocą biblioteki Handlebars.

Dzięki temu wszystko, co potrzebujemy zrobić w metodzie renderInMenu, to wywołać metodę templates.menuProduct i przekazać jej dane produktu:

Możesz sprawdzić za pomocą console.log, czy jest generowany kod HTML, ale jego czytanie byłoby bardzo żmudne. Poczekamy z tym do końca pracy nad metodą renderInMenu.

Krok 2 – tworzenie elementu DOM

Kod HTML to jednak zwykły tekst, a my potrzebujemy elementu DOM, który będziemy w stanie naprawdę "wcisnąć" gdzieś na naszą stronę. Najlepiej zapamiętaj to tak: HTML to zwykły string, a element DOM to obiekt wygenerowany przez przeglądarkę na podstawie kodu HTML. Obiekt, który ma właściwości (np. innerHTML czy metody (np. getAttribute).

JS nie ma wbudowanej metody, która służy do tego celu – dlatego skorzystamy z jednej z funkcji zawartych w obiekcie utils. Przygotowaliśmy go dla Ciebie, aby usprawnić nam pracę nad tym projektem. W tym wypadku użyjemy metody utils.createDOMFromHTML. Przyjmie ona jako argument kod HTML (tekst) i zwróci element DOM na nim oparty.

Zauważ, że stworzony element DOM zapisujemy od razu jako właściwość naszej instancji. To dobra praktyka. Dzięki temu będziemy mieli do niego dostęp również w innych metodach instancji. Nie tylko w renderInMenu.

Krok 3 - znajdujemy kontener menu

Ten krok nie powinien już stanowić dla Ciebie problemu – użyjemy metody querySelector do znalezienia kontenera produktów, którego selektor mamy zapisany w select.containerOf.menu. Znaleziony element zapiszemy w stałej menuContainer.

Krok 4 – dodajemy stworzony element na stronę

Wreszcie, ostatni krok – za pomocą metody appendChild dodajemy stworzony element do menu!

Produkty już się renderują!

Teraz możemy się w końcu przyjrzeć, jak wyglądają produkty oferowane przez naszą pizzerię. Jak widzisz, każdy produkt ma:

  • nagłówek z tytułem i ceną,
  • opis,
  • opcje (tylko w niektórych produktach),
  • wybór ilości produktów,
  • ponownie wyświetloną cenę,
  • oraz guzik dodania do koszyka.

Oczywiście, na razie tylko wyświetlamy produkty – zmiana opcji czy ilości jeszcze nie ma stworzonej funkcjonalności.

Może Cię zdziwić ponowne wyświetlanie ceny – cena w nagłówku jest ceną "bazową", tzn. przy zaznaczeniu domyślnych opcji i wybraniu jednej sztuki produktu. Cena przy guziku dodania do koszyka będzie natomiast ceną z uwzględnieniem opcji oraz ilości. Tę funkcjonalność wspólnie napiszemy nieco później.

Analiza danych źródłowych i szablonu

Zanim przejdziemy dalej, chcemy wytłumaczyć, jak to się stało, że za sprawą paru linijek kodu wygenerowały się produkty na stronie.

Otwórz pliki src/index.html oraz src/js/data.js. Najlepiej będzie, jeśli oba pliki będą widoczne jednocześnie.

Cała magia tego rozwiązania polega na tym, że szablon (w index.html) jest dostosowany do danych (z data.js). Kod JS naszej klasy Product, którą przed chwilą napisaliśmy, w ogóle nie musi (na razie) wiedzieć jakie informacje znajdują się w obiekcie z danymi produktu – po prostu przekazuje je do szablonu.

Podstawowe właściwości

Nasz szablon generuje pojedynczy produkt, więc parametry takie jak name, price czy description brane są bezpośrednio z właściwości pojedynczego produktu w dataSource.products.

Wstawianie obrazków

Teraz przejdźmy na chwilę na sam koniec szablonu. Znajdziemy tam pętlę. Spójrz na źródło danych – znajdź w nim tablicę images z kodem HTML obrazków. Zdecydowaliśmy się na takie rozwiązanie, aby dało się wstawić kilka obrazków dla każdego produktu.

Jeśli dziwi Cię użycie {{{ this }}}, już tłumaczymy. Słowo this odnosi się tutaj do pojedynczego elementu, po którym iteruje pętla {{#each images}}. W to miejsce zostanie wstawiony pojedynczy element tablicy images.

A dlaczego użyliśmy potrójnych nawiasów klamrowych? Handlebars traktuje właściwości wstawiane za pomocą podwójnych nawiasów klamrowych jako tekst. Możesz zmienić te nawiasy na podwójne – zobaczysz wtedy, że zamiast obrazków wyświetlił się kod HTML. Gdybyśmy chcieli wyświetlać np. fragment kodu w kursie programowania, ten tryb tekstu byłby całkiem przydatny.

W naszym przypadku jednak chcemy, aby kod HTML był traktowany jak kod HTML, a nie jak tekst do wyświetlenia. Użycie potrójnych nawiasów klamrowych umożliwia nam właśnie tę zmianę zachowania szablonu.

Opcje produktu

Teraz przechodzimy do najciekawszej części – opcji produktu. Opcje podzieliliśmy na "kategorie", które nazwaliśmy params (skrót od parameters, czyli właściwości). W naszej strukturze danych, np. dla pizzy, parametrem będzie rodzaj ciasta, rodzaj sosu, czy zestaw składników na pizzy. Zobaczmy najpierw, jak wygląda struktura danych.

Spójrz np. właśnie na params w pizzy (plik data.js):

params: {
  sauce: {
    label: 'Sauce',
    type: 'radios',
    options: {
      tomato: {label: 'Tomato', price: 0, default: true},
      cream: {label: 'Sour cream', price: 2},
    },
  },
  toppings: {
    label: 'Toppings',
    type: 'checkboxes',
    options: {
      olives: {label: 'Olives', price: 2, default: true},
      redPeppers: {label: 'Red peppers', price: 2, default: true},
      greenPeppers: {label: 'Green peppers', price: 2, default: true},
      mushrooms: {label: 'Mushrooms', price: 2, default: true},
      basil: {label: 'Fresh basil', price: 2, default: true},
      salami: {label: 'Salami', price: 3},
    },
  },
  crust: {
    label: 'pizza crust',
    type: 'select',
    options: {
      standard: {label: 'standard', price: 0, default: true},
      thin: {label: 'thin', price: 2},
      thick: {label: 'thick', price: 2},
      cheese: {label: 'cheese in edges', price: 5},
      wholewheat: {label: 'wholewheat', price: 3},
      gluten: {label: 'with extra gluten', price: 0},
    },
  },
},

Każdy parametr ma:

  • label, czyli swoją nazwę wyświetlaną na stronie,
  • type, czyli typ parametru, który decyduje o tym, czy wyświetli się lista checkboksów (można wybrać wiele opcji), lista radio-buttonów czy select,
  • options, czyli zestaw możliwych opcji dla danego parametru.

Każda z opcji ma:

  • label, czyli swoją nazwę wyświetlaną na stronie,
  • price, czyli cenę za ten dodatek,
  • default, czyli opcjonalne ustawienie, które mówi, że ta opcja ma być domyślnie wybrana i została już wliczona w cenę tego produktu.

W szablonie tworzymy pętlę, która będzie iterować po wszystkich elementach obiektu params. Znajdź tę linię kodu:

{{#each params as |param paramId| }}

Ta pętla wygląda nieco inaczej niż poznane wcześniej pętle, ponieważ zdecydowaliśmy się na nazwanie kluczy i wartości w każdej iteracji. Będą to: klucz paramId oraz wartość param.

Ten zapis może wydawać się dziwny, ale sama zasada działania jest prosta – to po prostu pętla. Gdybyśmy pisali ją w JS-ie, wyglądałaby tak:

for(let paramId in params){
  const param = params[paramId];
  // ...
}

Potrzebowaliśmy nazwać klucz parametru, ponieważ będziemy go wykorzystywać wewnątrz kolejnych pętli.

Idąc dalej, używamy np. {{#ifEquals type "checkboxes"}} do rozpoznania, czy dany param ma type równe checkbox. Handlebars nie ma wbudowanej metody ifEquals – ma wbudowane tylko if, które potrafi sprawdzać wyłącznie wartości prawdziwe i fałszywe (a konkretniej – truth i false).

Handlebars umożliwia jednak dodawanie własnych bloków. W pliku src/js/functions.js zawarliśmy definicję bloku ifEquals, aby umożliwić nam wybór pomiędzy checkboksami, radio-buttonami, a selectem. Nie musisz rozumieć działania kodu w functions.js – zastosowaliśmy po prostu jedno z rozwiązań dostępnych w internecie po wyszukaniu frazy "Handlebars if equals". Podobnie, jeśli będziesz mieć potrzebę rozbudowania jakiegoś pluginu, możesz szukać w internecie rozwiązań podpowiedzianych przez innych. ;)

Wreszcie, w każdym z przypadków (checkboxy, radio-buttony i select) wyświetlamy label parametru, a następnie tworzymy pętlę iterującą po wszystkich opcjach tego parametru. Dla każdej z nich wstawiamy odpowiedni kawałek kodu HTML, zawierający:

  • label, czyli nazwę tej opcji (nie parametru!),
  • price, czyli cenę tej opcji,
  • paramId, czyli klucz parametru,
  • @key, czyli klucz opcji,
  • oraz warunek sprawdzający, czy ta opcja jest domyślna.

Niewielkim wyjątkiem jest select, w którym paramId używamy na <select>, a nie w pętli iterującej po opcjach.

Ta cała pętla dla parametrów, zawierająca bloki ifEqual, z których każdy ma swoją pętlę, w której używamy kluczy parametrów i kluczy opcji – to może przyprawiać o zawrót głowy. Nie przejmuj się, to faktycznie jest trochę skomplikowane.

Dlatego poświęć chwilę na lepsze zrozumienie tego kodu. Wybierz sobie jedną opcję (np. jeden składnik na pizzę) i znajdź go w strukturze danych. Zobacz, jakie ma właściwości, jaki ma klucz, i w jakim obiekcie (parametrze) się znajduje.

Następnie na stronie zbadaj element, który wyświetla ten składnik. Zobacz, gdzie są wykorzystane klucze parametru i opcji, a gdzie nazwa i cena. Wtedy spójrz jeszcze raz na szablon w index.html i znajdź fragment szablonu odpowiedzialny za wygenerowanie tej opcji na stronie.

Te wszystkie aspekty łączą się ze sobą w jedną całość! W źródle danych produkt ma parametry, które mają opcje. Na stronie mamy wyświetlone "kategorie opcji", i w nich opcje. W szablonie mamy pętlę dla parametrów, a w niej pętle dla opcji.

Zrób to samo ćwiczenie jeszcze parę razy dla różnych opcji w różnych parametrach różnych produktów. Z każdym kolejnym podejściem będzie nieco łatwiej zrozumieć, że dane źródłowe, szablon i elementy na stronie są tylko różnymi aspektami tej samej struktury danych.

Nie martw się, jeśli wydaje Ci się to skomplikowane. Faktycznie takie jest, i dlatego omawiamy kod, który dla Ciebie przygotowaliśmy, zamiast tworzyć go wspólnie. Nie myśl sobie jednak, że napisaliśmy tę strukturę danych ot tak. Robiliśmy to tak samo, jak piszemy ten skrypt – krok po kroku, zaczynając od prostej struktury danych i prostego szablonu, i rozwijając je na przemian. Dodawaliśmy kolejne obiekty w danych źródłowych i dostosowywaliśmy szablon do tych zmian. I tak w kółko...

No, ale dość o tym. Wystarczy, że rozumiesz ogólny zarys działania naszego skryptu – klasa Product za pomocą metody renderInMenu bierze dane źródłowe produktu, "wrzuca je" do szablonu, i tak powstaje kod HTML pojedynczego produktu. Ponieważ metoda renderInMenu jest uruchamiana w konstruktorze klasy, to przy tworzeniu każdej nowej instancji dla danego produktu, od razu renderuje się on na stronie.

Jak widzisz, stworzenie naszej pierwszej klasy było mniej skomplikowane, niż wytłumaczenie struktury danych źródłowych i szablonu. Jednak w naszych plikach projektu nie kryje się już nic do wytłumaczenia – teraz możemy spokojnie przejść do rozbudowy klasy Product, aby dodawać kolejne funkcjonalności naszym produktom w menu.

Podsumowanie

Zanim ruszymy dalej, podsumujmy krótko, jak na tym etapie działa nasza aplikacja. JS zaczyna swoje działanie od uruchomienia metody app.init. Ta wywołuje dwie kolejne – initData i initMenu.

Pierwsza (app.initData) ma zadanie przygotować nam łatwy dostęp do danych. Przypisuje więc do app.data (właściwości całego obiektu app) referencję do dataSource, czyli po prostu danych, z których będziemy korzystać z aplikacji. Znajduje się tam m.in. obiekt products ze strukturą naszych produktów.

Druga metoda (app.initMenu) jest wywoływana po pierwszej, gdyż korzysta z przygotowanej wcześniej referencji do danych (thisApp.data). Jej zadaniem jest przejście po wszystkich obiektach produktów z thisApp.data.products (cake, breakfast itd.) i utworzenie dla każdego z nich instancji klasy Product. Przy tworzeniu każdej instancji uruchamia się funkcja konstruktora, która uruchamia dla danego obiektu metodę renderInMenu. Ta tworzy element DOM wygenerowany na podstawie szablonu HTML reprezentujący właśnie dany produkt i "dokleja" go do strony. Czyli najprościej mówiąc, metoda app.initMenu przejdzie po każdym produkcie z osobna i stworzy dla niego instancję Product, czego wynikiem będzie również utworzenie na stronie reprezentacji HTML każdego z produktów w thisApp.data.products.

Czy na tym etapie wszystko jest dla Ciebie jasne? Jeśli nie, to zatrzymaj się na moment i przeanalizuj swój kod jeszcze raz.

8.5. Uruchamiamy akordeon

Na stronie naszej pizzerii już pojawiły się produkty, ale przez te wszystkie opcje produktów trochę ciężko jest się zorientować w menu. Znacznie czytelniej byłoby, gdyby domyślnie na stronie widoczne były tylko nazwy produktów, może opisy i ew. ceny. W końcu dokładnie informacje mogłyby się pojawiać dopiero po kliknięciu na produkt. Przyznaj, że zapewniłoby to znacznie lepszy UX (User Experience). Dlatego domyślnie ukryjemy opcje – zostawimy tylko nagłówki i opisy, a opcje będą widoczne po kliknięciu!

Do czego dążymy

Efekt, który chcemy osiągnąć powinien być następujący:

image

Domyślnie wszystkie produkty powinny być "zwinięte". Kliknięcie na nagłówek produktu powinno "rozwinąć" produkt, jeśli jest aktualnie "zwinięty" oraz "zwinąć" jeśli jest aktualnie "rozwinięty". Do tego, jeśli podczas próby rozwinięcia jakiegoś produktu, jest aktywny również inny (np. był rozwinięty wcześniej), to trzeba go "zwinąć". Taki mechanizm nazywa się często akordeonem.

Zmiana w szablonie

Domyślnie wszystkie produkty powinny być "zwinięte". W stylach jest nawet odpowiednia reguła, która zapewnia, że opcje są chowane. Dlaczego więc u nas, ona nie działa? Dlatego, że na razie ten styl nadpisujemy.

W szablonie nadano domyślnie produktom klasę active, która nadpisuje wspomniany wcześniej styl i zapewnia części z opcjami widoczność. Skąd taki pomysł? No cóż, masz nas. Przygotowaliśmy tę klasę właśnie z myślą o dodaniu do strony mechanizmu akordeonu.

Obecność takiej klasy to dla nas bardzo dobra wiadomość. Wystarczy bowiem, że usuniemy klasę active z szablonu produktów, a elementy zaczną być "domyślnie" zwinięte. Następnie będziemy musieli dopisać tylko funkcję, która po kliknięciu na nagłówek produktu, taką klasę doda (aby produkt rozwinąć) albo zabierze (aby produkt rozwinąć) i... gotowe! Tak naprawdę więc metoda, którą za chwilę napiszemy, będzie jedynie nadawać i odbierać klasę active – a konkretniej, klasę zdefiniowaną w classNames.menuProduct.wrapperActive. Brzmi prosto?

Ale po kolei. Pierwsza zmiana, którą musimy zrobić, to usunięcie tej klasy z szablonu. Zlokalizuj go w pliku index.html, znajdź w nim element <article> z klasami product i active, a następnie skasuj klasę active.

Teraz na stronie powinny być widoczne wszystkie produkty z opisami, ale bez opcji, wyboru ilości i guzika dodania do koszyka.

image

Przygotowanie nowej metody

Pozostaje nam teraz przejście do JS-a i przygotowanie wspomnianej wcześniej metody. W klasie Product odnajdź więc deklaracją metody renderInMenu i dodaj pod nią nową – initAccordion. Pamiętaj, aby dodać do niej tę samą pierwszą linię, co w pozostałych metodach, czyli deklarację stałej thisProduct. Jak zawsze – dla czytelności. Następnie w konstruktorze dodaj wywołanie metody initAccordion, tuż pod wywołaniem metody renderInMenu. Dlaczego? Przypomnijmy. To, że inicjujemy instancję danej klasy, nie oznacza, że z automatu uruchomią się metody w niej obecne. Tak naprawdę uruchamiana jest tylko jedna funkcja – constructor. Jeśli więc chcemy, żeby jakaś metoda uruchamiała się przy utworzeniu instancji, to po prostu musimy ją wywoływać w konstruktorze. W naszym przypadku to o tyle ważne, że faktycznie chcielibyśmy, żeby produkty od razu, od samego początku mogły być zwijane/rozwijane.

Planowanie algorytmu metody

Zastanówmy się teraz, jak ma działać metoda initAccordion.

Wiemy, że zmiana widoczności opcji ma się odbywać na kliknięcie – w tym celu mamy już nawet stworzony selektor select.menuProduct.clickable, które ułatwi nam dojście do nagłówka produktu. To właśnie nagłówek powinien być dla nasz najważniejszym elementem. To na nim uruchomimy nasłuchiwacz, który po wykryciu kliknięcia będzie uruchamiać proces "zwijania/rozwijania". Oczywiście eventem, na który będziemy oczekiwać, powinien być click.

Nasłuchiwacz oprócz informacji o tym, na jaki event ma nasłuchiwać, przyjmuje również referencje do funkcji callback, którą powinien włączyć w momencie jego wykrycia. Co ta funkcja powinna robić?

Najprościej mówiąc:

  • znaleźć aktywny produkt i (o ile istnieje) zabrać mu klasę active (classNames.menuProduct.wrapperActive),
  • nadawać lub odbierać (toggle) klasę zdefiniowaną w active (classNames.menuProduct.wrapperActive) na elemencie bieżącego produktu (thisProduct.element).

Do drugiego założenia potrzebna jest znajomość nowej wbudowanej metody. Oprócz remove i add obiekt classList na elemencie DOM ma jeszcze jedną ciekawą metodę, właśnie toggle. Działa ona tak, że jeśli danej klasy nie ma, to jest ona dodawana. Jeśli już jest, to jest ona zabierana. Można więc powiedzieć, że to trochę bardziej inteligentne połączenie metod remove i add.

Zobaczmy, jak będzie wyglądać ta funkcja z algorytmem wpisanym w komentarzach:

initAccordion(){
    const thisProduct = this;

    /* find the clickable trigger (the element that should react to clicking) */
    const clickableTrigger = ???;

    /* START: add event listener to clickable trigger on event click */
    clickableTrigger.addEventListener('click', function(event) {
      /* prevent default action for event */

      /* find active product (product that has active class) */

      /* if there is active product and it's not thisProduct.element, remove class active from it */

      /* toggle active class on thisProduct.element */
    });

  }
}

Jak widzisz, część kodu już dla Ciebie przygotowaliśmy. Twoją rolą będzie tylko jego dokończenie zgodnie z podpowiedziami w komentarzach.

Jeden komentarz może być dla Ciebie niejasny. Chodzi o:

// if there is active product and it's not thisProduct.element, remove class active from it

Zauważ, że nie tylko chcemy sprawdzić, czy udało się znaleźć aktywny produkt, ale również, czy nie jest on czasem tym produktem, na który klikamy. Dlaczego?

Pomyśl nad taką sytuacją. Powiedzmy, że aktualnie na stronie jest rozwinięty jeden produkt – pizza. Co stałoby się w momencie kliknięcia, gdybyśmy nie sprawdzali, czym jest znaleziony aktywny produkt? Oczekiwalibyśmy oczywiście, że kliknięcie na aktywny produkt, spowoduje jego zwinięcie. A co naprawdę by się stało?

Nasza funkcja najpierw jako aktywny produkt znalazłaby... naszą pizzę i ją zwinęła. Następnie nasz kod niżej, który ma "togglować" klasę na klikniętym produkcie, ustaliłby, że na naszym elemencie (pizzy) klasy active nie ma, więc by ją... ponownie dodał. Tym samym nasza pizza znowu by się rozwinęła. Chyba nie o to nam chodzi, prawda? ;)

Dlatego też, zanim schowasz aktywny produkt, to sprawdź, czy nie jest on czasem równy thisProduct.element. Chodzi nam o schowanie innego aktualnie aktywnego produktu, o ile taki jest. Nie tego, na który właśnie klikamy.

Składnia handlera w listenerze eventu

W poprzednich modułach używaliśmy tego typu składni listenera i handlera:

function titleClickHandler(){
  console.log('clicked');
}

const buttonTest = document.getElementById('button-test');

buttonTest.addEventListener('click', titleClickHandler);

Teraz, kiedy już lepiej rozumiesz ich działanie, zastosujemy składnię, z którą dużo częściej się spotkasz. Zmodyfikujemy powyższy przykład, aby pokazać Ci, jak będziemy pisać od teraz:

const buttonTest = document.getElementById('button-test');

buttonTest.addEventListener('click', function(){
  console.log('clicked');
});

Zamiast używać funkcji o nazwie titleClickHandler, podajemy anonimową (nienazwaną) funkcję jako drugi argument metody addEventListener.

To podejście jest często preferowane, ponieważ zmniejsza liczbę nazwanych funkcji i ułatwia zrozumienie kodu. Będziemy się jednak starać, aby kod funkcji handlera nie był zbyt długi – gdyby miał nie mieścić się w całości na ekranie monitora (bez scrollowania), to część kodu możemy przenieść (zgodnie ze starym pomysłem) do osobnej metody. Wtedy funkcję handlera można ograniczyć do wywołania kilku metod naszego produktu.

Zadanie: dokończenie akordeonu

Dokończenie funkcji initAccordion jest Twoim zadaniem w tym submodule. Wszystkie operacje, które są do wykonania, były już wykorzystywane przez nas w poprzednim projekcie.

Oczekiwany efekt

Po odświeżeniu strony, pod żadnym z produktów nie powinny być widoczne opcje (razem z wyborem ilości i guzikiem dodania do koszyka).

Efektem tego zadania powinna być funkcjonalność, dzięki której po kliknięciu w nazwę produktu, opcje powinny się pokazać, ale tylko dla tego produktu.

Jeśli jeden produkt ma wyświetlone opcje, to kliknięcie w nazwę innego produktu powinno zamknąć opcje wcześniej otwartego produktu, i jednocześnie pokazać opcje produktu, którego nazwę kliknęliśmy.

Jeśli czujesz się pewnie, to od razu bierz się do pracy. Jeśli jednak masz jakieś problemy, skorzystaj z poniższych wskazówek.

Do rozwiązania tego zadania przydadzą Ci się te informacje:

  • aby odnaleźć clickableTrigger, użyj selektora select.menuProduct.clickable,
  • zastanów się, czy szukać elementu clickableTrigger w całym dokumencie, czy może jednak dokładniej?
  • element bieżącego produktu to thisProduct.element, czyli to na tym elemencie będziemy dodawać i usuwać (toggle) klasę zdefiniowaną w select.menuProduct.clickable,
  • aby sprawdzić, czy dany element DOM udało się znaleźć, wystarczy sprawdzić, czy nie jest nullem if(activeProduct),
  • do sprawdzenia, czy dany aktywny produkt jest różny od elementu bieżącego produktu, wystarczy wykorzystać takiego samego operatora porównania, jak przy porównywaniu liczb.

8.6. Obliczamy cenę produktu

Mamy już akordeon, który pokazuje opcje pojedynczego produktu – ale na razie same opcje niczego nie robią. Możemy zaznaczać czy odznaczać dane wybory, ale to... niczego nie zmieni. Czas się za to zabrać!

Obsługa opcji produktu

Cel jest następujący. Chcemy, żeby zmiana jakiejkolwiek opcji produktu, z automatu powodowała ponowne przeliczenie ceny. Tak, aby odpowiadała ona wybranym dodatkom. W końcu pizza z dodatkowym ananasem, czy salami, musi kosztować więcej niż wersja podstawowa, prawda? Tak samo, jak zabranie z pizzy domyślnie dodawanych oliwek, taką cenę powinno zmniejszyć.

Załóż, że cena produktu zawarta w danych źródłowych (w data.js) uwzględnia domyślnie zaznaczone opcje. To ważna informacja. Musimy bowiem uważać, żeby nie dodawać ceny takich opcji do ceny produktu. Za to, jeśli odznaczymy którąś z domyślnych opcji, cena produktu powinna się zmniejszyć.

Oznacza to po prostu tyle, że:

  • jeśli zaznaczymy opcję, która nie jest domyślna, cena produktu musi się zwiększyć o cenę tej opcji,
  • jeśli odznaczymy opcję, która jest domyślna, cena produktu musi się zmniejszyć o cenę tej opcji.

Brzmi prosto, prawda? Teraz musimy tylko zastanowić się, jak ten algorytm zaimplementować.

Na pewno będzie nam zależało, żeby nasz skrypt reagował na każdą zmianę wartości – np. zaznaczenie którejś opcji. Naturalnie, możesz pomyśleć, że kiedy zaznaczymy jakąś opcję, to jej wartość powinna zostać dodana lub odjęta od ceny produktu. Takie podejście jest jednak ryzykowne, ponieważ jest podatne na błędy.

Potencjalne błędy

Nie będziemy teraz wchodzić w bardzo techniczne szczegóły potencjalnych problemów z takim podejściem, ale są sytuacje, w których pola formularza mogą zmieniać wartość, a nasz skrypt nie będzie o tym powiadomiony. Przykładem może być wtyczka do przeglądarki, która zapamiętuje stan formularza.

Użytkownik może wejść na stronę, zaznaczyć wszystkie składniki, a następnie odświeżyć stronę – jego wtyczka wtedy mogłaby zaznaczyć wszystkie składniki bez informowania o tym naszego skryptu. Jest to sytuacja, której być może nie da się uniknąć, ale jeszcze gorzej, jeśli użytkownik wtedy zacznie odznaczać składniki, a cena produktu zacznie spadać – może nawet poniżej zera!

Aby nasz skrypt działał lepiej, zastosujemy inne rozwiązanie – przy jakiejkolwiek zmianie opcji, cena produktu zostanie policzona na nowo, w oparciu o wszystkie wybrane opcje. Przy okazji, to podejście uprości nasz algorytm, bo nie będziemy musieli przejmować się tym, która z opcji została zmieniona. Przy każdej zmianie będziemy wykonywać te same obliczenia.

Wyszukanie elementów DOM

Zanim rozpoczniemy pisanie obsługi opcji produktu, zadbajmy nieco o porządek. Różne metody naszej klasy będą potrzebowały odnosić się do poszczególnych elementów DOM, stworzonych niedawno na podstawie szablonu Handlebars. Raz będziemy chcieli odnieść sie do formularza z opcjami, innym razem do diva z ceną itd. Bez sensu za każdym razem szukać tych elementów w danej metodzie. Lepiej przygotować te referencje raz i to w taki sposób, żeby były dostępne we wszystkich metodach, a więc zapisując je jako właściwości this. Może za to odpowiadać nawet jedna metoda.

Dzięki temu będziemy mieć jedną metodę służącą odnalezieniu elementów w kontenerze produktu, a inne będą tylko z tych przygotowanych referencji korzystały. Posłuży nam ona trochę za spis treści, dzięki któremu będziemy mieli pewność, że nie wyszukujemy tego samego elementu wielokrotnie.

W klasie Product, pod deklaracją metody renderInMenu, dodaj tę metodę:

getElements(){
  const thisProduct = this;

  thisProduct.accordionTrigger = thisProduct.element.querySelector(select.menuProduct.clickable);
  thisProduct.form = thisProduct.element.querySelector(select.menuProduct.form);
  thisProduct.formInputs = thisProduct.form.querySelectorAll(select.all.formInputs);
  thisProduct.cartButton = thisProduct.element.querySelector(select.menuProduct.cartButton);
  thisProduct.priceElem = thisProduct.element.querySelector(select.menuProduct.priceElem);
}

Oczywiście nazwa ta, to jak zawsze, tylko nasze "widzimisię". Musisz przyznać jednak, że dobrze oddaje rolę tej funkcji.

Następnie zadbaj o to, aby była ona uruchamiana w konstruktorze, tuż pod thisProduct.renderInMenu();.

Zauważ, że pierwszym z zapisanych elementów jest thisProduct.accordionTrigger. To referencja do tego samego elementu, którego szukaliśmy wcześniej w initAccordion! Po co nam dwie referencje do tego samego elementu? Skoro teraz ta referencja jest już przygotowana wcześniej (metoda getElements uruchamia się przed initAccordion), to możemy od razu z niej skorzystać, zamiast ponownie wyszukiwać ten sam element. Zrób to teraz:

thisProduct.accordionTrigger.addEventListener('click', function(event) {
  ...

Zauważ, że stała clickableTrigger jest już teraz zbędna.

Kolejne dwa elementy to referencja do formularza z opcjami oraz wszystkich jego kontrolki (checkboksy, selecty, etc.). Zaraz będą nam potrzebne. W końcu będziemy musieli jakoś ustalić, czy dana opcja została wybrana i czy trzeba zmienić cenę.

Ćwiczenie

Przeanalizuj te linie kodu i zobacz, jakie selektory wykorzystujemy (są zapisane w obiekcie select) oraz spróbuj określić, co zostanie wyszukane. Zwróć szczególną uwagę na sytuację, w której szukamy elementów za pomocą querySelector lub querySelectorAll.

Następnie sprawdź za pomocą console.log, czy udało Ci się dobrze przewidzieć rezultat.

Dodajemy akcję do formularza

Na razie nie myślimy o tym, w jaki sposób obliczać cenę produktu. Piszemy nową funkcjonalność krok po kroku. Udało nam się już przygotować dostęp do potrzebnych elementów, więc kolejnym krokiem będzie zadbanie o to, aby reagować na zmiany w tych elementach. A konkretnie właśnie w kontrolkach formularza. Zmiana w polach tekstowych, checkboxach czy selectach powinna być dla nas równoznaczna z włączeniem funkcji, która od nowa przeliczy cenę. Będziemy chcieli więc reagować na zmianę wartości każdej kontrolki formularza. Pomoże nam w tym event change, który jest emitowany domyślnie przez przeglądarkę właśnie w tej sytuacji. Na tym jednak nie koniec.

Warto pamiętać również, że każdy formularz posiada domyślną akcję – wysłanie formularza z przeładowaniem strony. Na razie nie podłączaliśmy żadnej funkcjonalności do pola wyboru ilości sztuk zamawianego produktu – sprawdź, co się stanie, kiedy spróbujesz wpisać inną liczbę sztuk, a następnie naciśniesz enter.

Strona się przeładowała, a w dodatku teraz mamy bardzo długi adres strony – zostały w nim zawarte wszystkie wybrane przez nas opcje tego produktu. Zdecydowanie nie chcemy zostawiać takiej domyślnej akcji! Całe szczęście, formularz wywołuje na sobie event submit tuż zanim będzie wysłany, i to niezależnie od tego, czy wysłanie jest wywołane przez wciśnięcie entera, czy kliknięcie guzika. Dzięki temu będziemy mogli nasłuchiwać na niego i w momencie wykrycia, wykorzystać znane nam już wyrażenie event.preventDefault();, które powstrzyma domyślną akcję – czyli wysłanie formularza z przeładowaniem strony.

Przygotowanie metod

Zaczniemy od stworzenia nowych metod. Przejdź w kodzie klasy Product pod deklarację metody initAccordion i dodaj dwie nowe metody: initOrderForm i processOrder. Na razie dodaj w nich tylko const thisProduct = this; oraz console.log informujący o nazwie metody, w której się znajduje.

W konstruktorze dodaj wywołanie nowo stworzonych metod, tuż pod wywołaniem metody initAccordion. W końcu metody same się nie inaczej nie wywołają. W tym momencie w konsoli powinna wyświetlać się nazwa każdej z tych funkcji i to osobno dla każdego z produktów (czyli np. processOrder wyświetli się 4 razy, jeśli mamy 4 produkty na stronie). W końcu każda nowa instancja to wywołanie konstruktora dla nowo utworzonego obiektu.

Mamy przygotowane metody, teraz zajmiemy się napisaniem pierwszej z nich.

Event listenery dla formularza

Metoda initOrderForm będzie uruchamiana tylko raz dla każdego produktu. Będzie odpowiedzialna za dodanie listenerów eventów do formularza, jego kontrolek, oraz guzika dodania do koszyka.

thisProduct.form.addEventListener('submit', function(event){
  event.preventDefault();
  thisProduct.processOrder();
});

for(let input of thisProduct.formInputs){
  input.addEventListener('change', function(){
    thisProduct.processOrder();
  });
}

thisProduct.cartButton.addEventListener('click', function(event){
  event.preventDefault();
  thisProduct.processOrder();
});

Jak widzisz, w każdej funkcji callback wywołujemy metodę processOrder bez żadnych argumentów. Dodatkowo, dla eventów submit na formularzu oraz click na guziku blokujemy domyślną akcję – czyli odpowiednio: wysłanie formularza z przeładowaniem strony oraz zmianę adresu strony po kliknięciu w link (nasz guzik tylko wygląda jak guzik – w rzeczywistości jest linkiem).

Dokładnie tak jak tłumaczyliśmy wcześniej, ten kod jest bardzo prosty dzięki podejściu, które przyjęliśmy. Niezależnie od tego, który z tych eventów się wydarzy, wykonamy metodę processOrder, która obliczy cenę produktu. Klikniesz button "Add to cart" – uruchomi się przeliczanie ceny, zmienisz jakąś opcję – włączy się przeliczanie ceny, klikniesz enter (wysyłka formularza) – to samo.

Za jakiś czas wrócimy do tego kodu i w handlerze kliknięcia w guzik dodamy kod, który będzie dodawał ten produkt do koszyka. Na razie jednak wystarczy, że uruchamia metodę processOrder.

Możesz sprawdzić działanie tego kodu – przy każdej zmianie opcji, próbie wysłania formularza lub kliknięcie w guzik, uruchomi się console.log w metodzie processOrder i wyświetli komunikat w konsoli.

Obliczanie ceny produktu

Wszystko fajnie, ale ta funkcja musi faktycznie to przeliczanie wykonać. Czas więc na najciekawszą część najnowszej funkcjonalności naszej strony – obliczanie ceny produktu z uwzględnieniem wybranych opcji.

Jak to w ogóle ma działać?

Pomysł jest prosty. Funkcja ta musi pobrać domyślną cenę produktu z thisProduct.data oraz sprawdzić jakie opcje są wybrane. Jeśli jakaś opcja jest wybrana, a nie jest domyślna, to cena podstawowa musi się zwiększyć o koszt tego dodatku. Jeśli z kolei opcja jest odznaczona, a była domyślna, to cena podstawowa musi się zmniejszyć. Na końcu taka nowa, przeliczona cena, powinna zostać zaktualizowana na stronie (thisProduct.priceElem).

Będzie to wyglądać następująco:

image

Wydaje się, że mamy dwie drogi, aby dojść do tego celu.

  1. Przejdziemy po wszystkich polach formularza i sprawdzimy każdy z nich. Ustalimy, czy pole jest zaznaczone, czy nie, a następnie sprawdzimy, jak dana opcja jest opisana w thisProduct.data.params. Dlaczego tam? Bo to właśnie tam znajdziemy dokładne informacje o tym, jakie opcje są do wyboru, jakie są domyślne i jakie są ich ceny. Dzięki temu będziemy w stanie ustalić, czy trzeba zwiększyć lub zmniejszyć cenę oraz o ile.

Dla przypomnienia, spójrz na ten obiekt params. Na przykładzie obiektu breakfast:

params: {
  coffee: {
    label: 'Coffee type',
    type: 'radios',
    options: {
      latte: {
        label: 'Latte',
        price: 1,
        default: true
      },
      cappuccino: {
        label: 'Cappuccino',
        price: 1
      },
      espresso: {
        label: 'Espresso',
        price: 1
      },
      macchiato: {
        label: 'Macchiato ',
        price: 1
      },
    },
  },
},
  1. Jest jeszcze druga opcja, którą właśnie my podążymy. Przechodzimy po wszystkich opcjach produktu (thisProduct.data.params) i sprawdzamy każdą z nich. Mamy bezpośrednią informację o domyślności (właściwość default) czy cenie (właściwość price). Formularz wykorzystujemy tylko po to, aby ustalić, czy dana opcja była zaznaczona. Tak, aby ustalić, co mamy zrobić z ceną. Zwiększyć ją? Zmniejszyć? A może nie ruszać?

Zauważ, że niezależnie od wybranej drogi, musimy mieć dostęp do formularza, a dokładniej do jego pól.

Odczytywanie wartości z formularza

Zanim więc przejdziemy do algorytmu obliczania ceny, musimy odczytać, które opcje formularza zostały wybrane. Aby ułatwić to zadanie, przygotowaliśmy funkcję utils.serializeFormToObject. Nie musisz interesować się, jak ona dokładnie działa "pod maską". Dla Ciebie ważne jest to, że potrafi ona po otrzymaniu obiektu formularza przekonwertować go do zwykłego obiektu JS.

To bardzo duże ułatwienie. W teorii można by dostać się do pól formularza przy użyciu metody querySelectorAll, mamy nawet odpowiedni selektor, ale metoda ta jednak zwróci nam tablicę elementów HTML, czyli inputów, selectów itd. To znacznie utrudniłoby nam pracę. Aby dojść do konkretnej wartości zamiast całego elementu (np. <input type="text" ...), musielibyśmy poczynić dodatkowe kroki... Dzięki funkcji utils.serializeFormToObject otrzymamy od razu prosty i przyjazny obiekt JS z samymi wartościami.

Funkcja ta będzie więc zwracać obiekt, w którym:

  • kluczami będą wartości atrybutów name kontrolek formularza,
  • wartościami będą tablice, zawierające wartości atrybutów value wybranych opcji.

Zatem np. jeśli w formularzu pizzy, ktoś wybrałby jako sauce wartość tomato, a z dodatków (toppings) wskazałby na olives i salami, to otrzymalibyśmy następujący obiekt:

{
  sauce: ['tomato'],
  toppings: ['olives', 'salami']
}

Musisz przyznać, że to dość fajna struktura, a już na pewno lepsza niż lista elementów DOM [<input type="text" ... />, <input type="text" ... />, ...]. Dzięki niej możemy bardzo łatwo ustalić, czy dana opcja jest wybrana.

Chcesz wiedzieć, czy wybrano olives? Wystarczy sprawdzić, czy ten obiekt zawiera w sauce element olives.

if(obj.sauce.includes('olives')) { console.log('Wybrano!'); }

Metoda includes użyta na tablicy sprawdza właśnie, czy dany element jest w niej dostępny. Jeśli jest, to wiemy, że był zaznaczony, jeśli nie – nie był. Jeszcze do tego wrócimy, ale przyznaj, że zapowiada się to ciekawie.

No dobrze. Chwalimy funkcję utils.serializeFormToObject, mówimy, co nam zwróci, ale chyba najlepiej zobaczyć to na własne oczy! Sprawdźmy, czy faktycznie tak będzie – w metodzie processOrder dodaj następujący kod:

Następnie odznacz jedną z dowolnych opcji, np. w pizzy, i zajrzyj do konsoli:

image

Spójrz tylko! Tak jak mówiliśmy. Możesz teraz porównać to, co widzisz aktualnie w formularzu i to, jaki obiekt otrzymaliśmy. Odpowiada on dokładnie temu, co jest zaznaczone.

Algorytm obliczania ceny

Teraz, kiedy wiemy już co wybrano w formularzu, wydaje się, że możemy przejść do dalszej części funkcji. Zanim jednak to zrobimy, wytłumaczmy, dlaczego odrzucamy pierwszą ze wspomnianych wcześniej opcji.

Wydaje się, że teraz kiedy mamy już odczytane wartości z formularza, wystarczy dla każdej z nich odczytać cenę i dodać ją (lub odjąć) do ceny produktu, prawda? Chwila, nie tak szybko – sprawdź, co się stanie, kiedy odznaczysz wszystkie domyślne opcje.

Nasz obiekt, zwracany przez utils.serializeFormToObject, zawiera tylko zaznaczone opcje – a przecież w tej sytuacji powinniśmy od ceny produktu odjąć ceny domyślnych opcji, które odznaczyliśmy. To oznacza, że nie możemy iterować po tym obiekcie, bo nie zajęlibyśmy się odznaczonymi opcjami...

Stąd też postawiliśmy na drugą opcję. Aby upewnić się, że przeanalizowaliśmy istniejące możliwości, będziemy po prostu iterować po wszystkich. Ma to sens, prawda?

Tutaj musimy się jednak na moment zatrzymać. Pamiętaj, że opcje do wyboru są skategoryzowane. Spójrz chociażby na params produkcie pizza.

params: {
      sauce: {
        label: 'Sauce',
        type: 'radios',
        options: {
          tomato: {
            label: 'Tomato',
            price: 0,
            default: true
          },
          cream: {
            label: 'Sour cream',
            price: 2
          },
        },
      },
      toppings: {
        label: 'Toppings',
        type: 'checkboxes',
        options: {
          olives: {
            label: 'Olives',
            price: 2,
            default: true
          },
          redPeppers: {
            label: 'Red peppers',
            price: 2,
            default: true
          },
          greenPeppers: {
            label: 'Green peppers',
            price: 2,
            default: true
          },
          mushrooms: {
            label: 'Mushrooms',
            price: 2,
            default: true
          },
          basil: {
            label: 'Fresh basil',
            price: 2,
            default: true
          },
          salami: {
            label: 'Salami',
            price: 3
          },
        },
      },
      crust: {
        label: 'pizza crust',
        type: 'select',
        options: {
          standard: {
            label: 'standard',
            price: 0,
            default: true
          },
          thin: {
            label: 'thin',
            price: 2
          },
          thick: {
            label: 'thick',
            price: 2
          },
          cheese: {
            label: 'cheese in edges',
            price: 5
          },
          wholewheat: {
            label: 'wholewheat',
            price: 3
          },
          gluten: {
            label: 'with extra gluten',
            price: 0
          },
        },
      },
    },

Nie mamy tutaj listy 5 czy 10 różnych opcji, które mogą być zaznaczone. Zamiast tego jest obiekt "kategorii" (np. sauce, toppings, crust) i dopiero każda z nich ma swoje własne obiekty z opcjami (options). A więc np. sauce ma w sobie opcje tomato i cream, a toppings posiada olives, redPeppers itd.

Jaki z tego wniosek? Nie jesteśmy w stanie "przejść" za pomocą jednej pętli od razu po wszystkich opcjach. Zamiast tego musimy najpierw utworzyć pętlę, która przejdzie po wszystkich kategoriach, a dopiero w środku kolejną (drugą), która dla danej kategorii przejdzie jeszcze po wszystkich jej opcjach.

Wewnątrz tej drugiej pętli będziemy musieli zastosować logikę, o której wspomnieliśmy na początku tego submodułu, czyli:

  • jeśli jest zaznaczona opcja, która nie jest domyślna, cena produktu musi się zwiększyć o cenę tej opcji,
  • jeśli nie jest zaznaczona opcja, która jest domyślna, cena produktu musi się zmniejszyć o cenę tej opcji.

Pozostaje jeszcze pytanie: jak zweryfikować, czy dana opcja jest zaznaczona? Wystarczy, że sprawdzimy:

  • czy w obiekt formData zawiera właściwość o kluczu takim, jak klucz parametru (powinien, ale lepiej się upewnić), oraz
  • czy w tablicy zapisanej pod tym kluczem znajduje się klucz opcji (wspomniana wcześniej metoda (includes)).

Jeśli oba te warunki są prawdziwe, to znaczy, że opcja jest zaznaczona. Jeśli jednocześnie ta opcja nie ma właściwości default (lub jest ona fałszem), to powinniśmy zwiększyć cenę produktu. Jeśli natomiast opcja nie jest zaznaczona, ale ma prawdziwą właściwość default (czyli powinna być domyślna), to zmniejszyć. W obu przypadkach będziemy zmieniać cenę produktu o cenę danej opcji.

Rozpiszmy razem początek tej metody:

processOrder() {
  const thisProduct = this;

  // covert form to object structure e.g. { sauce: ['tomato'], toppings: ['olives', 'redPeppers']}
  const formData = utils.serializeFormToObject(thisProduct.form);
  console.log('formData', formData);

  // set price to default price
  let price = thisProduct.data.price;

  // for every category (param)...
  for(let paramId in thisProduct.data.params) {
    // determine param value, e.g. paramId = 'toppings', param = { label: 'Toppings', type: 'checkboxes'... }
    const param = thisProduct.data.params[paramId];
    console.log(paramId, param);

    // for every option in this category
    for(let optionId in param.options) {
      // determine option value, e.g. optionId = 'olives', option = { label: 'Olives', price: 2, default: true }
      const option = param.options[optionId];
      console.log(optionId, option);
    }
  }

  // update calculated price in the HTML
  thisProduct.priceElem.innerHTML = price;
}

Co tu się dokładnie dzieje? Początek już znasz. Przygotowujemy dostęp do formularza w postaci wygodnego JS-owego obiektu.

// set price to default price
let price = thisProduct.data.price;

Kolejna instrukcja też nie jest raczej niczym niezwykłym. Tworzymy zmienną, w której będziemy trzymać naszą cenę. Startowo otrzymuje ona domyślną cenę produktu, ale oczywiście później, wraz ze sprawdzaniem zaznaczonych opcji jej, wartość będzie odpowiednio zwiększana lub zmniejszana. Zaznaczono dodatkową opcję – price się zwiększy. Odznaczono domyślną opcję – price się zmniejszy.

Co do obu pętli poniżej, to już wytłumaczyliśmy, skąd taki pomysł. Jedyne co może Cię dziwić, to linijki:

const param = thisProduct.data.params[paramId];
const option = param.options[optionId];

Po co nam one? Przypomnijmy, pętla for..in w zmiennej iteracyjnej zwraca zawsze tylko nazwę właściwości. Czyli np. paramId dla toppings będzie niecałym obiektem:

toppings: {
       label: 'Toppings',
       type: 'checkboxes',
       options: {
         olives: {
           label: 'Olives',
           price: 2,
           default: true
           ...

...tylko samą nazwą właściwości – toppings. Ta dodatkowa linijka dba więc o to, aby dostać się do całego obiektu dostępnego pod tą właściwością.

Dokładnie to samo mamy też w przypadku drugiej pętli. Również chcemy mieć obiekt do całej opcji, a nie tylko jej nazwy.

// update calculated price in the HTML
thisProduct.priceElem.innerHTML = price;

Ostatnia linijka nie wymaga komentarza. Po prostu wpisujemy przeliczoną cenę do elementu w HTML-u:

image

Zadanie: wdrożenie obliczania ceny

Twoim zadaniem będzie dokończenie metody processOrder tak, aby poprawnie modyfikowała price, zgodnie z tym, jakie opcje są zaznaczone w formularzu. Tak naprawdę większość funkcji jest już gotowa. Jedyne miejsce, w którym będziesz pracować, to pętla drugiego poziomu, gdzie przechodzimy po opcjach danej kategorii. To właśnie tam musisz dodać kod, który będzie sprawdzał, czy dana opcja (optionId) danej kategorii (paramId) jest wybrana w formularzu (formData), a następnie ustalał, czy trzeba zwiększyć lub zwiększyć cenę. Albo... nie robić z nią nic, bo i taka sytuacja może się pojawić. Jeśli np. opcja jest bowiem wybrana i jest określona jako domyślna, to jest już wliczona w cenę startową. Nie musimy wtedy modyfikować price. Podobnie w sytuacji, kiedy odznaczona opcja nie jest opcją domyślną.

Algorytm, który opisaliśmy powyżej, powinien być dla Ciebie wystarczający do napisania dokończenia tej metody. Zacznij od zapisania tego algorytmu w formie komentarzy, może Ci to pomóc w rozplanowaniu pracy.

Po wykonaniu każdego kroku, sprawdź jego poprawność za pomocą console.log. Dzięki temu będzie Ci łatwiej zrealizować to zadanie.

Poznaj debugger

Im większy staje się nasz kod i częściej chcemy sprawdzić, co się w nim właściwie dzieje, tym bardziej męczące staje się korzystanie z console.log. Debugowanie z pomocą tej metody jest możliwe i dotychczas jakoś sobie z tym radziliśmy, ale nie jest to najbardziej komfortowy sposób. Na szczęście przeglądarka Google Chrome udostępnia nam alternatywne narzędzie, które potrafi znacznie przyśpieszyć i ułatwić ten proces – debugger.

Jego znajomość nie jest obowiązkowa, ale daj mu szansę. Jego zalety są przeogromne. Od możliwości zatrzymania działania kodu w dowolnym momencie, poprzez sprawdzanie wartości zmiennych, czy nawet "podglądanie" co dokładnie znajduje się w danym momencie w pamięci.

Zachęcamy do przeczytania krótkiego wprowadzenia w naszej dokumentacji. Jeśli nie masz problemów z angielskim, zachęcamy również do obejrzenia dłuższego materiału z oficjalnego kanału twórców Google Chrome.

Popracuj nad tym zadaniem co najmniej przez pół godziny (a najlepiej godzinę) – jeśli po tym czasie nie uda Ci się go rozwiązać, możesz sprawdzić poniższe podpowiedzi.

Sugestie pomocne w rozwiązaniu zadania

Zaczniemy od małej podpowiedzi. Rozpiszemy Ci punkt po punkcie, co Twój algorytm powinien robić.

  1. Zacznij od ustalenia, czy w formData istnieje właściwość o nazwie zgodnej z nazwą kategorii, a jeśli tak, to czy zawiera ona nazwę sprawdzanej opcji. Jeśli zawiera, to będzie to oznaczać, że opcja jest wybrana.
  2. Jeśli tak jest, to sprawdź, czy opcja ta jest opcją domyślną. Jeśli nie, to musisz zwiększyć price o koszt tej danej opcji.
  3. Jeśli jednak nie jest wybrana, to sprawdź, czy opcja jest domyślna. Jeśli tak, to musisz zmniejszyć price o koszt tej opcji.

Dodatkowe wskazówki:

  • Koniecznie przejrzyj w naszym poradniku informacje dotyczące obiektów i pętli for-in, tablic oraz operatorów logicznych i przypisania.
  • Jeśli mamy obiekt option, który ma właściwość default równą false, to wynikiem !option.default będzie prawda. Jeśli ten obiekt nie ma takiej właściwości, to samo wyrażenie będzie również prawdziwe, ponieważ !undefined jest thruthy.
  • Warunki "opcja zaznaczona i niedomyślna" oraz "opcja niezaznaczona i domyślna" nigdy nie będą jednocześnie spełnione dla tej samej opcji, więc zamiast dwóch bloków if, użyj if oraz else if.
  • Pamiętaj, że thisProduct.priceElem to cena przy guziku dodania do koszyka! Cena w nagłówku produktu nigdy nie będzie się zmieniać! Upewnij się, że patrzysz na właściwą cenę!
Algorytm metody processOrder

Jeśli pierwsza podpowiedź to dla Ciebie za mało, poniżej możesz znaleźć ten sam pomysł, ale rozpisany już za pomocą komentarzy i z kawałkiem dodatkowego kodu.

// for every option in this category
for(let optionId in param.options) {
  // determine option value, e.g. optionId = 'olives', option = { label: 'Olives', price: 2, default: true }
  const option = param.options[optionId];
  console.log(optionId, option);

  // check if there is param with a name of paramId in formData and if it includes optionId
  if(formData[paramId] && formData[paramId].includes(optionId)) {
    // check if the option is not default
    if(????) {
      // add option price to price variable
    }
  } else {
    // check if the option is default
    if(????) {
      // reduce price variable
    }
  }

}

Oczekiwany efekt

Po odświeżeniu strony, tak jak do tej pory, wyświetlą się produkty z ukrytymi opcjami. Kiedy wyświetlimy np. opcje dla pizzy, zobaczymy cenę produktu tuż przy guziku dodawania do koszyka.

Kiedy zaznaczymy którąś z domyślnie niezaznaczonych opcji, cena powinna wzrosnąć o koszt tej opcji. Kiedy odznaczymy którąś z opcji, cena powinna spaść.

W rezultacie, po odznaczeniu wszystkich opcji, cena powinna być niższa od początkowej (która jest wyświetlana w nagłówku produktu). I odwrotnie – po zaznaczeniu wszystkich opcji, cena przy guziku powinna być wyższa niż początkowa.

Zmiana zamawianej ilości nie powinna na razie wpływać na cenę – jeszcze nie napisaliśmy kodu obsługującego tę funkcjonalność.

Uwaga! Nie idź dalej, dopóki metoda processOrder nie będzie dla Ciebie całkowicie jasna. W razie potrzeby zapytaj o pomoc na czacie. Nasza aplikacja pizzerii to na tym etapie kursu naprawdę duże wyzwanie i nie da się jej przejść "na pół gwizdka". Nawet małe braki w zrozumieniu na początku pracy mogą powodować wiele problemów na późniejszych etapach. Dlatego nie śpiesz się, realizuj go na spokojnie, dokładnie czytając cały materiał.

8.7. Dodajemy obsługę obrazków

Jak widzisz, stworzenie dotychczasowych skryptów kosztowało nas sporo pracy. Na szczęście to się opłaca. Nasza strona wygląda coraz ciekawiej. Za chwilę rozwiniemy ją jeszcze bardziej i sprawimy, że ilustracje pizzy i sałatki będą się zmieniać w zależności od wybranych opcji! Zajmie nam to tylko kilka linijek kodu, ale wrażenie będzie świetne i właśnie tych parę linijek sprawi, że każdy, kto obejrzy ten projekt, zapamięta go na długo!

Analiza danych źródłowych

Zanim będziemy mogli napisać tę funkcjonalność, musimy zrozumieć, co mamy do zrobienia. Otwórz data.js i znajdź obiekt images dla pizzy. To tablica elementów img, które są wykorzystywane przy renderowaniu diva z prezentacją pizzy.

image

Zwróć uwagę, że tylko jeden obrazek ma wyłącznie klasę active – pozostałe mają oprócz tego jeszcze jedną klasę. Ta klasa składa się z dwóch członów – pierwszy z nich to klucz parametru, a drugi to klucz opcji, np. sauce-tomato czy toppings-salami. Możesz nawet zajrzeć do obiektu params dla pizzy, aby sprawdzić, czy na pewno się zgadzają. Co nam to daje? Wystarczy znać nazwę kategorii i nazwę opcji, aby łatwo przygotować selektor, który wybierze odpowiadający takiej parze obrazek! I właśnie za chwilę to zrobimy.

Jednak najpierw musimy zrobić coś jeszcze, a mianowicie usunąć klasę active z każdego obrazka, który ma jakąkolwiek inną klasę. Nie usuwaj klasy active, jeśli jest to jedyna klasa danego obrazka. Za co odpowiada ta klasa? Obrazki składników są domyślnie ukryte (styl display: none). To klasa active sprawia, że obrazek jest widoczny, nadpisując display – dlatego np. obrazek ciasta pizzy ma mieć klasę active, bo ma być zawsze widoczny. Pozostałe obrazki jednak już jej mieć nie powinny. Odpowiadają bowiem składnikom, które mogą być pokazane, ale nie zawsze muszą. Powinny być widoczne tylko wtedy, kiedy odpowiadające im pola w formularzu będą zaznaczone. Zatem jeśli np. zaznaczymy oliwki w formularzu, to chcielibyśmy, żeby obrazek toppings-olives się pokazał. Musimy mu więc w takiej sytuacji nadać klasę active. Jeśli jednak potem taką opcję odznaczymy, to chcemy go schować (zabrać klasę active). Za dodawanie lub usuwanie klas active dla tych obrazków, będzie również odpowiadać funkcja processOrder.

Uzupełnienie metody getElements

Zanim jednak do niej przejdziemy, musimy przygotować dostęp do diva, w którym te obrazki się znajdą. Dla czytelności. Moglibyśmy bowiem szukać np. topping-olives w całym thisProduct.element, ale po co? Lepiej przygotować sobie referencje do samego diva, w którym te obrazki są i potem w razie potrzeby to w nim szukać konkretnego obrazka.

Odnajdź więc metodę getElements i dodaj do niej właściwość thisProduct.imageWrapper. Ma być ona referencją do pojedynczego elementu o selektorze zapisanym w select.menuProduct.imageWrapper. Pamiętaj, aby wyszukiwać go w konkretnym produkcie, a nie całym dokumencie.

Modyfikujemy metodę processOrder

Ponownie wracamy do drugiej pętli. Tej, która przechodzi po wszystkich opcjach z danej kategorii. Na razie zwiększamy tam lub zmniejszamy cenę. Teraz dodatkowo będziemy wyszukiwać obrazek pasujący do danej pary kategoria-opcja i zależnie od tego, czy opcja jest wybrana, pokazywać go lub chować. Oczywiście dzięki wykorzystaniu patentu z klasą active. Tak jak mówiliśmy, dodanie klasy active będzie powodować pokazanie obrazka, zabranie klasy active schowanie.

Ta druga pętla to idealni miejsce na umieszczenie kodu odpowiedzialnego za obrazki, bo mamy w nim informacje:

  • jaki jest klucz parametru,
  • jaki jest klucz opcji,
  • czy ta opcja jest zaznaczona.

Może spróbujesz dodać ten kawałek kodu bez naszej pomocy?

Oczekiwany efekt

W rezultacie zmiana opcji pizzy lub sałatki powinna zmieniać widoczność obrazków. Dzięki temu na stronie powinna wyświetlać się np. pizza ze składnikami, które się wybrało.

image

Pamiętaj, że nie wszystkie opcje mają swoje obrazki – nie zdziw się, że w niektórych produktach w ogóle nie zmieniają się. Zastosowaliśmy to rozwiązanie tylko w niektórych z nich.

Podsumowanie

Zwróć uwagę, że nadal nasz kod JS nie ma potrzeby wiedzieć, że np. na pizzy mogą być oliwki. Cały czas polega na danych z data.js i operuje na "każdej opcji każdego parametru", a nie na konkretnych wartościach. Dzięki temu nasz skrypt jest bardzo uniwersalny i pozwala na stosowanie tego samego kodu do każdego produktu.

Możesz zastanawiać się, dlaczego zdecydowaliśmy się, aby w danych źródłowych w data.js umieścić osobny obiekt images, zamiast dodać właściwość image do każdej opcji (albo chociaż niektórych). I tak właściwie, dlaczego w data.js wstawiliśmy kod HTML obrazków, zamiast tylko ich adresów src?

Wybraliśmy takie podejście, ponieważ gdyby to był prawdziwy, komercyjny projekt, dawałoby ono większą elastyczność dla administratora strony. Dzięki temu mógłby np. zdefiniować wymiary lub przesunięcia obrazków, albo dodać dowolny kod HTML, jaki byłby potrzebny. Zakładamy, że w komercyjnym projekcie administrator strony miałby do dyspozycji panel administracyjny, w którym obrazki mogłyby być wybierane za pomocą formularza, a nie wpisywane jako kod HTML.

Jednak w tym projekcie nie będziemy zajmować się panelem administracyjnym. Za to strona pizzerii nabiera kolejnych funkcjonalności – w zależności od opcji produktu, zmienia się zarówno cena, jak i obrazek. Jest coraz lepiej, ale nie działa jeszcze wybór ilości. Tym właśnie zajmiemy się w następnym submodule.

Zadanie: implementacja algorytmu

Mamy opisany algorytm, została już tylko jego implementacja. Chyba dasz sobie z nim radę? Tym razem nie będzie zbyt dużo podpowiedzi, ponieważ zadanie jest dużo prostsze. Wystarczy, że znajdziesz elementy wedle powyższego opisu i dla każdego ze znalezionych elementów nadasz/usuniesz wspomnianą klasę.

Cały kod, który musisz dodać, będzie dość krótki. To, co należy zrobić, to:

  1. Znalezienie obrazka o klasie .paramId-optionId w divie z obrazkami.
  2. Sprawdzenie, czy udało się go znaleźć (pamiętaj, że nie każdy produkt ma obrazki dla opcji).
  3. Jeśli się udało, to sprawdzenie, czy dana opcja jest zaznaczona. Jeśli jest, to należy pokazać taki obrazek. Jeśli nie jest, to należy go schować.

Zauważ, że teraz nie ma znaczenia już to, czy opcja była domyślna. Ważne jest tylko to, czy opcja jest wybrana/zaznaczona, czy nie. Jeśli jest, to musi pokazać obrazek, który tej opcji odpowiada. W przeciwnym razie taki obrazek musimy schować.

Uwaga! Nie wpisuj w classList.add czy classList.remove bezpośrednio klasy active, lecz skorzystaj ze stałej classNames.menuProduct.imageVisible. Oczywiście da to nam taki sam efekt, ale trzymajmy się tych samych praktyk przez cały projekt. Skoro wcześniej też przechowywaliśmy nazwy klas w stałych, to róbmy to dalej.

Spróbuj rozwiązać to zadanie bez naszej pomocy. Jeśli jednak naprawdę się zatniesz, to skorzystaj z naszej podpowiedzi, jak zacząć.

Zacznij od znalezienia obrazka odpowiadającego konkretnej parze kategoria-opcja.

const optionImage = thisProduct.imageWrapper.querySelector(???);

Selektor, który musisz przygotować, powinien wyglądać mniej więcej tak .toppings-olives. Dlaczego tak? Bo właśnie tego typu klasy mają wszystkie obrazki odpowiadające opcjom. Mówiliśmy o tym na początku zadania. Oczywiście nazwa kategorii nie powinna być zawsze równa toppings, tylko zależna od tego, do jakiej kategorii (paramu) należy opcja. Podobnie jak zamiast olives powinna być nazwa aktualnej opcji. Obie te informacje masz już w tej pętli for dostępne. Musisz tylko odnaleźć nazwy zmiennych, które je przechowują, a następnie stworzyć z ich pomocą poprawny selektor.

Zastanawiasz się, jak sprawdzić, czy obrazek udało się znaleźć? Metoda querySelector, jeśli nie udało się odnaleźć elementu, zawsze zwraca null. A null użyte w if da nam false... Z drugiej strony, jeśli element uda się znaleźć, to ten użyty w warunku zwróci na true.

const optionImage = thisProduct.imageWrapper.querySelector(???);
if(optionImage) {
  // Yes! We've found it!
}

Mała refaktoryzacja

Możesz zauważyć, że po dodaniu Twojego nowego kodu, masz już dwa ify, które sprawdzają ten sam dłuższy warunek.

if(formData[paramId] && formData[paramId].includes(optionId)) {

Jeśli chcesz, możesz wyciągnąć go do jednej stałej, a potem po prostu wykorzystać w obu miejscach.

const optionSelected = formData[paramId] && formData[paramId].includes(optionId);
if(optionSelected) {

...

if(optionImage) {
  if(optionSelected) {
    ...
  }
}

8.8. Podsumowanie

W tym module udało nam się zaimplementować prawie wszystkie funkcjonalności dotyczące wyłącznie produktów w menu:

  • generujemy elementy DOM produktu w menu pizzerii,
  • dodajemy obsługę akordeonu, który pokazuje opcje co najwyżej jednego produktu,
  • obliczamy cenę produktu w zależności od wybranych opcji,
  • wyświetlamy warstwowe ilustracje, które odzwierciedlają wybrane opcje produktu.

Na kolejny zostawimy sobie jeszcze jedną:

  • umożliwiamy wybranie kilku sztuk produktu, przeliczając jego cenę.

Efekt naszej pracy to jedno. Ważniejsze jest to, że nauczyliśmy się tworzyć klasy oraz wykorzystywać ich instancje. Dzięki temu nasz kod jest dużo bardziej uporządkowany i pozwala nam na dalszą stosunkowo bezbolesną rozbudowę tego projektu.

W następnym module, oprócz opcji wyboru liczby sztuk, zajmiemy się również implementacją funkcjonalności koszyka oraz komunikacją z serwerem! W ten sposób umożliwimy wysyłanie zamówienia i zapisywanie go na serwerze.

Ten moduł na pewno mocno Cię wymęczył, ale nie przejmuj się na zapas. W dzisiejszych czasach tego typu duże projekty raczej wykonuje się przy użyciu większych frameworków czy bibliotek, np. Reacta czy Angulara. Robienie tego w czystym JS, to czasami spora katorga. Dlaczego więc my pozostajemy przy czystym JS-ie? Dlatego, że w taki sposób znacznie więcej się nauczysz. Możesz więc spodziewać się wzmożonej pracy w najbliższych tygodniach, ale... to nie znaczy, że zawsze tak będzie. Niedługo podobne projekty będziemy wykonywać już z pomocą Reacta i okaże się, że będzie to znacznie prostsze. Dlatego i poziom trudności też znacznie się zmniejszy. Zaufaj nam, warto wytrzymać tę próbę ognia, wyjdziemy z niej mocniejsi! :)

8.9. Quiz powtórkowy

Na koniec tego modułu przygotowaliśmy dla Ciebie quiz powtórkowy. Pomoże Ci on powtórzyć wiedzę z poprzednich części.

Odpowiedzi tego quizu nie są nigdzie zapisywane, więc są tylko do Twojej wiadomości. Ten quiz ma Ci posłużyć jako pomoc w nauce – dlatego pod każdym pytaniem znajdziesz guzik, który sprawdzi poprawność Twoich odpowiedzi oraz poda Ci wyjaśnienie zagadnienia poruszanego w tym pytaniu.

1. O czym należy pamiętać przy podłączaniu pluginów?

Wyjaśnienie

Umieszczenie wszystkich plików pluginów w katalogu vendor pozwala nam na oddzielenie tzw. bibliotek, czyli kodu, z którego będziemy korzystać, ale nie będziemy go modyfikować. Dzięki temu łatwiej będzie zaktualizować plugin do nowej wersji. Ponadto, katalog css będzie zawierał wyłącznie pliki skonwertowane z plików SCSS w katalogu sass.

W plikach HTML style podłączamy w <head>, a by jak najszybciej rozpoczęło się ich pobieranie. Dzięki temu strona szybciej się wyświetli. W przypadku plików JS jest odwrotnie – chcemy, aby wczytały się nieco później, i nie opóźniały wyświetlenia strony. Dlatego podłączamy je tuż przed zamknięciem </body>.

2. Używanie szablonów HTML, np. Mustache czy Handlebars, pozwala nam na:

Wyjaśnienie

Szablony HTML są bardzo przydatnym narzędziem. Ich główną rolą jest uniknięcie umieszczania kodu HTML i treści wyświetlanych na stronie wewnątrz plików JS. Pozwala też na wielokrotne wykorzystanie szablonów, dzięki czemu można uniknąć powtarzania kodu.

Zastosowanie szablonów pozwala też na łatwiejsze – ale nie automatyczne – tworzenie różnych wersji językowych strony. Wynika to z tego, że cała treść wyświetlana na stronie znajduje się w pliku HTML, więc do wszystkich wersji językowych możemy używać tego samego kodu JS.

Prędkość działania strony z szablonami HTML zależy od sposobu ich implementacji. Zawartość kodu HTML wyświetli się szybciej, ale za to treści wyświetlane przy pomocy szablonów wyświetlą się później. Z tego względu nie możemy jednoznacznie powiedzieć, że wykorzystanie szablonów przyspiesza działanie strony – ale świadome ich zastosowanie może w tym pomóc.

3. Wybierz prawdziwe zdania dotyczące programowania obiektowego:

Wyjaśnienie

Pojęcia klasy i instancji łatwo jest pomylić, dlatego warto je sobie przypomnieć.

Klasa jest szablonem, wedle którego możemy tworzyć obiekty, czyli instancje tej klasy. Klasa może posiadać metody, które będą wspólne dla wszystkich instancji tej klasy.

Instancję klasy tworzymy za pomocą słowa kluczowego new. W momencie stworzenia nowej instancji zostanie uruchomiony konstruktor tej klasy. Zarówno w konstruktorze, jak i w pozostałych metodach, słowo this wskazuje na daną instancję.

;